From 50138cf9c2c92fd5c870b84d2a58264a7dfb6bab Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 20 Aug 2025 15:36:48 +0900 Subject: [PATCH 001/527] feat: create-repostory --- .../com/scriptopia/demo/repository/AuctionRepository.java | 8 ++++++++ .../demo/repository/EffectGradeDefRepository.java | 8 ++++++++ .../scriptopia/demo/repository/GameSessionRepository.java | 8 ++++++++ .../com/scriptopia/demo/repository/GameTagRepository.java | 7 +++++++ .../com/scriptopia/demo/repository/HistoryRepository.java | 8 ++++++++ .../com/scriptopia/demo/repository/ItemDefRepository.java | 7 +++++++ .../scriptopia/demo/repository/ItemEffectRepository.java | 8 ++++++++ .../demo/repository/ItemGradeDefRepository.java | 7 +++++++ .../demo/repository/LocalAccountRepository.java | 7 +++++++ 9 files changed, 68 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/repository/AuctionRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/GameTagRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/HistoryRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/ItemDefRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/ItemEffectRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java new file mode 100644 index 00000000..827778ef --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.Auction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuctionRepository extends JpaRepository { + +} diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java new file mode 100644 index 00000000..64d03747 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.EffectGradeDef; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EffectGradeDefRepository extends JpaRepository { + +} diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java new file mode 100644 index 00000000..5816908d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.GameSession; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GameSessionRepository extends JpaRepository { + +} diff --git a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java new file mode 100644 index 00000000..a2f966cc --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.GameTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GameTagRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java new file mode 100644 index 00000000..ecf2d243 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.History; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HistoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/scriptopia/demo/repository/ItemDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemDefRepository.java new file mode 100644 index 00000000..df2e0a1a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/ItemDefRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.ItemDef; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ItemDefRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/ItemEffectRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemEffectRepository.java new file mode 100644 index 00000000..169422b9 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/ItemEffectRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.ItemEffect; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ItemEffectRepository extends JpaRepository { + +} diff --git a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java new file mode 100644 index 00000000..9e88330e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.ItemGradeDef; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ItemGradeDefRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java new file mode 100644 index 00000000..53cb3447 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.LocalAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LocalAccountRepository extends JpaRepository { +} From 343892305500ef75df5407c5bc06d369823019c3 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:38:39 +0900 Subject: [PATCH 002/527] Revert "Feature/piaitem repository" --- src/main/java/com/scriptopia/demo/domain/User.java | 1 - .../com/scriptopia/demo/repository/PiaItemRepository.java | 8 -------- 2 files changed, 9 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 7d75e02d..ade7ef94 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -25,5 +25,4 @@ public class User { private String profileImgUrl; private Role role; - } diff --git a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java deleted file mode 100644 index 836af29d..00000000 --- a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.scriptopia.demo.repository; - -import com.scriptopia.demo.domain.PiaItem; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PiaItemRepository extends JpaRepository { - -} From b556e55da0a0dfeaf18ad1e66316ceeb1787ecbf Mon Sep 17 00:00:00 2001 From: juns0720 Date: Wed, 20 Aug 2025 15:44:44 +0900 Subject: [PATCH 003/527] feature: create databases --- .../com/scriptopia/demo/repository/PiaItemRepository.java | 8 ++++++++ .../scriptopia/demo/repository/SettlementRepository.java | 8 ++++++++ .../demo/repository/SharedGameFavoriteRepository.java | 8 ++++++++ .../scriptopia/demo/repository/SharedGameRepository.java | 8 ++++++++ .../demo/repository/SharedGameScoreRepository.java | 8 ++++++++ .../demo/repository/SocialAccountRepository.java | 8 ++++++++ .../com/scriptopia/demo/repository/TagDefRepository.java | 8 ++++++++ .../demo/repository/UserCharacterImgRepository.java | 8 ++++++++ .../scriptopia/demo/repository/UserItemRepository.java | 8 ++++++++ .../scriptopia/demo/repository/UserPiaItemRepository.java | 8 ++++++++ .../com/scriptopia/demo/repository/UserRepository.java | 8 ++++++++ 11 files changed, 88 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/SettlementRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/TagDefRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/UserItemRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java create mode 100644 src/main/java/com/scriptopia/demo/repository/UserRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java new file mode 100644 index 00000000..fb05f2c6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.LocalAccount; +import com.scriptopia.demo.domain.PiaItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PiaItemRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java b/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java new file mode 100644 index 00000000..dceba6a0 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.PiaItem; +import com.scriptopia.demo.domain.Settlement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SettlementRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java new file mode 100644 index 00000000..c5fbe2f0 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.SharedGame; +import com.scriptopia.demo.domain.SharedGameFavorite; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SharedGameFavoriteRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java new file mode 100644 index 00000000..b87a9d3e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.PiaItem; +import com.scriptopia.demo.domain.SharedGame; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SharedGameRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java new file mode 100644 index 00000000..d4adc80f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.SharedGame; +import com.scriptopia.demo.domain.SharedGameScore; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SharedGameScoreRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java new file mode 100644 index 00000000..e859da4d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.SharedGameScore; +import com.scriptopia.demo.domain.SocialAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SocialAccountRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java new file mode 100644 index 00000000..0be06bc7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.SocialAccount; +import com.scriptopia.demo.domain.TagDef; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagDefRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java new file mode 100644 index 00000000..d618a697 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserCharacterImg; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserCharacterImgRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java new file mode 100644 index 00000000..b522072f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserItemRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java new file mode 100644 index 00000000..e6f865b1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserPiaItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPiaItemRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/UserRepository.java b/src/main/java/com/scriptopia/demo/repository/UserRepository.java new file mode 100644 index 00000000..84b795ef --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/UserRepository.java @@ -0,0 +1,8 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.TagDef; +import com.scriptopia.demo.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} From 65782a7b229b6fa38b7a79eee3da9a9cea3420f4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 20 Aug 2025 18:02:27 +0900 Subject: [PATCH 004/527] waiting for jwt token --- .../demo/controller/AuctionController.java | 22 +++++++++++++++++++ .../demo/dto/auction/AuctionRequestDto.java | 12 ++++++++++ .../demo/service/AuctionService.java | 19 ++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/AuctionController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java create mode 100644 src/main/java/com/scriptopia/demo/service/AuctionService.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java new file mode 100644 index 00000000..26486f0a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -0,0 +1,22 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.auction.AuctionRequestDto; +import com.scriptopia.demo.service.AuctionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/trades") +public class AuctionController { + + private final AuctionService auctionService; + + // 판매 아이템 등록 + @PostMapping + public ResponseEntity createAuction(@RequestBody com.scriptopia.demo.dto.auction.AuctionRequestDto requestDto) { + return ResponseEntity.ok(auctionService.createAuction(requestDto)); + + } +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java new file mode 100644 index 00000000..f2f2a94c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.auction; + + +import com.scriptopia.demo.domain.TradeStatus; +import lombok.Data; + +@Data +public class AuctionRequestDto { + private String itemDefsId; + private TradeStatus tradeStatus; // ENUM이면 String으로 받아서 변환 + private Long price; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java new file mode 100644 index 00000000..964b26dd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -0,0 +1,19 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.dto.auction.AuctionRequestDto; +import org.springframework.stereotype.Service; + +@Service +public class AuctionService { + + public String createAuction(AuctionRequestDto requestDto) { + // requestDto에서 itemDefsId( uuid -> long) + // 위의 long으로 UserItem을 가져오고 그 안에 있는 유저 아이디가 해당 유저와 같은지 체크 + + // 모두 완료 후 auction에 추가 + + + + return "등록 완료: " + requestDto.getItemDefsId() + " / 가격: " + requestDto.getPrice(); + } +} From 9aed18b740c01794eeef92c0a94f0d744e1d83cc Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 20 Aug 2025 18:46:40 +0900 Subject: [PATCH 005/527] feat: if spring start create gradeDef --- .../demo/config/DataLoaderConfig.java | 58 +++++++++++++++++++ .../com/scriptopia/demo/domain/ItemDef.java | 2 + .../scriptopia/demo/domain/ItemGradeDef.java | 2 + .../repository/EffectGradeDefRepository.java | 5 +- .../repository/ItemGradeDefRepository.java | 5 ++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java new file mode 100644 index 00000000..43721451 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -0,0 +1,58 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.EffectGradeDef; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemGradeDef; +import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemGradeDefRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class DataLoaderConfig { + + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; + + @Bean + public ApplicationRunner dataLoader() { + return args -> { + // ✅ ItemGradeDef 기본 데이터 + saveItemGradeIfNotExists(Grade.COMMON, 1.0, 100L); + saveItemGradeIfNotExists(Grade.UNCOMMON, 1.0, 200L); + saveItemGradeIfNotExists(Grade.RARE, 1.0, 500L); + saveItemGradeIfNotExists(Grade.EPIC, 1.0, 1000L); + saveItemGradeIfNotExists(Grade.LEGENDARY, 1.0, 2000L); + + // ✅ EffectGradeDef 기본 데이터 + saveEffectGradeIfNotExists(Grade.COMMON, 100L, 0.1); + saveEffectGradeIfNotExists(Grade.UNCOMMON, 200L, 0.15); + saveEffectGradeIfNotExists(Grade.RARE, 500L, 0.2); + saveEffectGradeIfNotExists(Grade.EPIC, 1000L, 0.25); + saveEffectGradeIfNotExists(Grade.LEGENDARY, 2000L, 0.3); + }; + } + + private void saveItemGradeIfNotExists(Grade grade, double weight, long price) { + itemGradeDefRepository.findByGrade(grade).orElseGet(() -> { + ItemGradeDef def = new ItemGradeDef(); + def.setGrade(grade); + def.setWeight(weight); + def.setPrice(price); + return itemGradeDefRepository.save(def); + }); + } + + private void saveEffectGradeIfNotExists(Grade grade, long price, double weight) { + effectGradeDefRepository.findByGrade(grade).orElseGet(() -> { + EffectGradeDef def = new EffectGradeDef(); + def.setGrade(grade); + def.setPrice(price); + def.setWeight(weight); + return effectGradeDefRepository.save(def); + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index 88b0330a..4003c9eb 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -37,4 +37,6 @@ public class ItemDef { private MainStat mainStat; private LocalDateTime createdAt; + + private Long price; } diff --git a/src/main/java/com/scriptopia/demo/domain/ItemGradeDef.java b/src/main/java/com/scriptopia/demo/domain/ItemGradeDef.java index 80c014f2..162f1dc2 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemGradeDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemGradeDef.java @@ -17,4 +17,6 @@ public class ItemGradeDef { @Enumerated(EnumType.STRING) private Grade grade; // enum 필드 추가 + + private Long price; } diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index 64d03747..fa291f75 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -1,8 +1,11 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.EffectGradeDef; +import com.scriptopia.demo.domain.Grade; import org.springframework.data.jpa.repository.JpaRepository; -public interface EffectGradeDefRepository extends JpaRepository { +import java.util.Optional; +public interface EffectGradeDefRepository extends JpaRepository { + Optional findByGrade(Grade grade); } diff --git a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java index 9e88330e..7060c9fb 100644 --- a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java @@ -1,7 +1,12 @@ package com.scriptopia.demo.repository; +import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemGradeDef; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface ItemGradeDefRepository extends JpaRepository { + Optional findByGrade(Grade grade); + } From a2905a9d5ed355db75aa23173c178ad80a1d4ebf Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 20 Aug 2025 18:59:06 +0900 Subject: [PATCH 006/527] feature : gamesession add service --- .../controller/GameSessionController.java | 26 +++++++++++++ .../dto/gamesession/GameSessionRequest.java | 12 ++++++ .../dto/gamesession/GameSessionResponse.java | 12 ++++++ .../demo/service/GameSessionService.java | 39 +++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/GameSessionController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java create mode 100644 src/main/java/com/scriptopia/demo/service/GameSessionService.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java new file mode 100644 index 00000000..9f358da9 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.gamesession.GameSessionRequest; +import com.scriptopia.demo.service.GameSessionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class GameSessionController { + private final GameSessionService gameSessionService; + + @PostMapping("/game-session") + public ResponseEntity createGameSession(@RequestBody GameSessionRequest gameSessionRequest) { + // 게임 세션 정보 저장 + return gameSessionService.saveGameSession(gameSessionRequest); + } + + // 정보 불러오기 + + // 수정 + + // 삭제 +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java new file mode 100644 index 00000000..f3e90472 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GameSessionRequest { + private String token; +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java new file mode 100644 index 00000000..887dfd32 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GameSessionResponse { + private String sessionId; +} diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java new file mode 100644 index 00000000..db747bff --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -0,0 +1,39 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.GameSession; +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.gamesession.GameSessionRequest; +import com.scriptopia.demo.repository.GameSessionRepository; +import com.scriptopia.demo.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GameSessionService { + private final GameSessionRepository gameSessionRepository; + // User Service 리포 가져오기 + // 사용자 인증 부분 필요 + + @Transactional + public ResponseEntity saveGameSession(GameSessionRequest gameSessionRequest) { + // 토큰을 통한 사용자 인증 + GameSession gameSession = new GameSession(); + return ResponseEntity.ok(gameSessionRepository.save(gameSession)); + } + + @Transactional + public ResponseEntity updateGameSession(GameSessionRequest gameSessionRequest) { + // 토큰을 통한 사용자 인증 + GameSession gameSession = new GameSession(); + return ResponseEntity.ok(gameSessionRepository.save(gameSession)); + } + + @Transactional + public void deleteGameSession(GameSessionRequest gameSessionRequest) { + // 토큰을 통한 사용자 인증 + // 게임 세션 삭제 + } +} From b7882fbc9f6b8e4f632aacba0870d83bfaa0a5c9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 20 Aug 2025 19:32:59 +0900 Subject: [PATCH 007/527] feat: create itemDef to game or anything --- .../demo/controller/ItemController.java | 27 +++++++ .../demo/dto/items/ItemDefRequest.java | 32 +++++++++ .../demo/dto/items/ItemEffectRequest.java | 12 ++++ .../demo/service/ItemDefService.java | 72 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/ItemController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java create mode 100644 src/main/java/com/scriptopia/demo/service/ItemDefService.java diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java new file mode 100644 index 00000000..e8e64a60 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -0,0 +1,27 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.service.ItemDefService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/items") +@RequiredArgsConstructor +public class ItemController { + + private final ItemDefService itemDefService; + + @PostMapping + public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { + ItemDef savedItem = itemDefService.createItem(dto); + return ResponseEntity.ok(savedItem); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java new file mode 100644 index 00000000..ab785e99 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -0,0 +1,32 @@ +package com.scriptopia.demo.dto.items; + +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.MainStat; +import lombok.Data; + +import java.util.List; + +@Data +public class ItemDefRequest { + + private String name; + private String description; + private String picSrc; + + private ItemType itemType; + private MainStat mainStat; + + private Integer baseStat; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + + private Long itemGradeDefId; + private Long price; + + // 🔹 아이템 효과 리스트 + private List effects; + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java new file mode 100644 index 00000000..ca4cf982 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.items; + +import com.scriptopia.demo.domain.Grade; +import lombok.Data; + +@Data +public class ItemEffectRequest { + private String effectName; + private String effectDescription; + private Grade grade; + private Integer effectValue; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java new file mode 100644 index 00000000..e65eed7c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -0,0 +1,72 @@ +package com.scriptopia.demo.service; + + +import com.scriptopia.demo.domain.EffectGradeDef; +import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.domain.ItemEffect; +import com.scriptopia.demo.domain.ItemGradeDef; +import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.dto.items.ItemEffectRequest; +import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemDefRepository; +import com.scriptopia.demo.repository.ItemEffectRepository; +import com.scriptopia.demo.repository.ItemGradeDefRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class ItemDefService { + + private final ItemDefRepository itemDefRepository; + private final ItemEffectRepository itemEffectRepository; + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; + + @Transactional + public ItemDef createItem(ItemDefRequest dto) { + // 1️⃣ ItemGradeDef 조회 + ItemGradeDef gradeDef = itemGradeDefRepository.findById(dto.getItemGradeDefId()) + .orElseThrow(() -> new IllegalArgumentException("ItemGradeDef not found")); + + // 2️⃣ ItemDef 생성 + ItemDef itemDef = new ItemDef(); + itemDef.setName(dto.getName()); + itemDef.setDescription(dto.getDescription()); + itemDef.setPicSrc(dto.getPicSrc()); + itemDef.setItemType(dto.getItemType()); + itemDef.setMainStat(dto.getMainStat()); + itemDef.setBaseStat(dto.getBaseStat()); + itemDef.setStrength(dto.getStrength()); + itemDef.setAgility(dto.getAgility()); + itemDef.setIntelligence(dto.getIntelligence()); + itemDef.setLuck(dto.getLuck()); + itemDef.setPrice(dto.getPrice()); + itemDef.setItemGradeDef(gradeDef); + itemDef.setCreatedAt(LocalDateTime.now()); + + itemDefRepository.save(itemDef); + + // 3️⃣ ItemEffect 생성 + if (dto.getEffects() != null) { + for (ItemEffectRequest effectDto : dto.getEffects()) { + EffectGradeDef effectGradeDef = effectGradeDefRepository.findById(effectDto.getGrade().ordinal() + 1L) + .orElseThrow(() -> new IllegalArgumentException("EffectGradeDef not found")); + + ItemEffect effect = new ItemEffect(); + effect.setItemDefs(itemDef); + effect.setEffectGradeDef(effectGradeDef); + effect.setEffectName(effectDto.getEffectName()); + effect.setEffectValue(effectDto.getEffectValue()); + + itemEffectRepository.save(effect); + } + } + + return itemDef; + } +} \ No newline at end of file From 1cfc3239e15596d83801721c58c8c1fbd372747c Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 20 Aug 2025 20:03:00 +0900 Subject: [PATCH 008/527] feature : game-tag-config done --- .../demo/config/GameTagLoaderConfig.java | 29 +++++++++++++++++++ .../com/scriptopia/demo/domain/TagDef.java | 1 + .../demo/repository/TagDefRepository.java | 1 + 3 files changed, 31 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java diff --git a/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java new file mode 100644 index 00000000..8548376c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java @@ -0,0 +1,29 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.TagDef; +import com.scriptopia.demo.repository.TagDefRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class GameTagLoaderConfig implements CommandLineRunner { + private final TagDefRepository tagDefRepository; + + @Override + public void run(String... args) { + List tags = List.of("남성 인기", "시뮬레이션", "로맨스", "SF", "좀비", "생존", "격투", "모험", "탐험", + "전투", "판타지", "현대", "범죄", "아포칼립스", "드래곤", "던전"); + + for(String tagName : tags) { + if(!tagDefRepository.existsByTagName(tagName)) { + TagDef tagDef = new TagDef(); + tagDef.setTagName(tagName); + tagDefRepository.save(tagDef); + } + } + } +} diff --git a/src/main/java/com/scriptopia/demo/domain/TagDef.java b/src/main/java/com/scriptopia/demo/domain/TagDef.java index e90497cd..1ab662b1 100644 --- a/src/main/java/com/scriptopia/demo/domain/TagDef.java +++ b/src/main/java/com/scriptopia/demo/domain/TagDef.java @@ -16,4 +16,5 @@ public class TagDef { private Long id; private String tagName; + } diff --git a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java index 0be06bc7..70483a2f 100644 --- a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TagDefRepository extends JpaRepository { + boolean existsByTagName(String tagName); } From c686c4c677949f5c094c95f30569f76baa8c7a95 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 20 Aug 2025 20:21:50 +0900 Subject: [PATCH 009/527] feat: crate auction but jwt is not yet so dont test code --- .../demo/controller/AuctionController.java | 8 +-- ...ionRequestDto.java => AuctionRequest.java} | 2 +- .../demo/repository/AuctionRepository.java | 3 +- .../demo/service/AuctionService.java | 59 +++++++++++++++++-- 4 files changed, 60 insertions(+), 12 deletions(-) rename src/main/java/com/scriptopia/demo/dto/auction/{AuctionRequestDto.java => AuctionRequest.java} (88%) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 26486f0a..539aa771 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.auction.AuctionRequestDto; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -13,10 +12,11 @@ public class AuctionController { private final AuctionService auctionService; - // 판매 아이템 등록 @PostMapping - public ResponseEntity createAuction(@RequestBody com.scriptopia.demo.dto.auction.AuctionRequestDto requestDto) { - return ResponseEntity.ok(auctionService.createAuction(requestDto)); + public ResponseEntity createAuction( + @RequestBody com.scriptopia.demo.dto.auction.AuctionRequest requestDto, + @RequestHeader("token") String userId) { // 헤더에서 userId 가져오기 임시임 + return ResponseEntity.ok(auctionService.createAuction(requestDto, userId)); } } diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java similarity index 88% rename from src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java rename to src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java index f2f2a94c..78c7437f 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequestDto.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java @@ -5,7 +5,7 @@ import lombok.Data; @Data -public class AuctionRequestDto { +public class AuctionRequest { private String itemDefsId; private TradeStatus tradeStatus; // ENUM이면 String으로 받아서 변환 private Long price; diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 827778ef..71ae01a0 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -1,8 +1,9 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.Auction; +import com.scriptopia.demo.domain.UserItem; import org.springframework.data.jpa.repository.JpaRepository; public interface AuctionRepository extends JpaRepository { - + boolean existsByUserItem(UserItem userItem); } diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 964b26dd..6ffa120c 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -1,19 +1,66 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.dto.auction.AuctionRequestDto; +import com.scriptopia.demo.domain.Auction; +import com.scriptopia.demo.domain.TradeStatus; +import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.repository.AuctionRepository; +import com.scriptopia.demo.repository.UserItemRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + @Service +@RequiredArgsConstructor public class AuctionService { - public String createAuction(AuctionRequestDto requestDto) { - // requestDto에서 itemDefsId( uuid -> long) - // 위의 long으로 UserItem을 가져오고 그 안에 있는 유저 아이디가 해당 유저와 같은지 체크 + private final AuctionRepository auctionRepository; + private final UserItemRepository userItemRepository; + + @Transactional + public String createAuction(AuctionRequest requestDto, String userId) { + + // UUID(String) → Long 변환 (임시) + long userItemId; + try { + userItemId = Long.parseLong(requestDto.getItemDefsId()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid UserItem UUID"); + } + + // UserItem 조회 + UserItem userItem = userItemRepository.findById(userItemId) + .orElseThrow(() -> new IllegalArgumentException("UserItem not found")); + + // 유저 소유 여부 확인 + if (!userItem.getUser().getId().equals(Long.parseLong(userId))) { + throw new IllegalStateException("해당 아이템은 사용자가 소유하지 않았습니다."); + } + + // 거래 상태 확인 + if (userItem.getTradeStatus() != TradeStatus.OWNED) { + throw new IllegalStateException( + "해당 아이템은 현재 경매장에 올릴 수 없습니다. 상태: " + userItem.getTradeStatus()); + } - // 모두 완료 후 auction에 추가 + // 이미 경매장에 등록되어 있는지 확인 + if (auctionRepository.existsByUserItem(userItem)) { + throw new IllegalStateException("이미 경매장에 등록된 아이템입니다."); + } + // Auction 등록 + Auction auction = new Auction(); + auction.setUserItem(userItem); + auction.setPrice(requestDto.getPrice()); + auction.setCreatedAt(LocalDateTime.now()); + auctionRepository.save(auction); + // UserItem 상태 업데이트 + userItem.setTradeStatus(TradeStatus.LISTED); + userItemRepository.save(userItem); - return "등록 완료: " + requestDto.getItemDefsId() + " / 가격: " + requestDto.getPrice(); + return "등록 완료: 가격=" + requestDto.getPrice(); } } From 47c6c38c30bac7fe238ae42b389347ff35253290 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 20 Aug 2025 21:01:47 +0900 Subject: [PATCH 010/527] feature : game-tag-Name generate, Delete --- .../com/scriptopia/demo/TagDefController.java | 24 ++++++++++ .../demo/config/GameTagLoaderConfig.java | 6 +-- .../demo/dto/TagDef/TagDefCreateRequest.java | 12 +++++ .../demo/dto/TagDef/TagDefDeleteRequest.java | 12 +++++ .../demo/repository/TagDefRepository.java | 1 + .../demo/service/TagDefService.java | 46 +++++++++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/TagDefController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/TagDef/TagDefCreateRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java create mode 100644 src/main/java/com/scriptopia/demo/service/TagDefService.java diff --git a/src/main/java/com/scriptopia/demo/TagDefController.java b/src/main/java/com/scriptopia/demo/TagDefController.java new file mode 100644 index 00000000..c003e339 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/TagDefController.java @@ -0,0 +1,24 @@ +package com.scriptopia.demo; + +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; +import com.scriptopia.demo.service.TagDefService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class TagDefController { + private final TagDefService tagDefService; + + @PostMapping("/add-tag") + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, @RequestHeader("X-USER-ID")Long id) { + return tagDefService.addTagName(req, id); + } + + @DeleteMapping("/remove-tag") + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, @RequestHeader("X-USER-ID")Long id) { + return tagDefService.removeTagName(req, id); + } +} diff --git a/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java index 8548376c..fb2963a5 100644 --- a/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/GameTagLoaderConfig.java @@ -16,10 +16,10 @@ public class GameTagLoaderConfig implements CommandLineRunner { @Override public void run(String... args) { List tags = List.of("남성 인기", "시뮬레이션", "로맨스", "SF", "좀비", "생존", "격투", "모험", "탐험", - "전투", "판타지", "현대", "범죄", "아포칼립스", "드래곤", "던전"); + "전투", "판타지", "현대", "범죄", "아포칼립스", "드래곤", "던전"); - for(String tagName : tags) { - if(!tagDefRepository.existsByTagName(tagName)) { + for (String tagName : tags) { + if (!tagDefRepository.existsByTagName(tagName)) { TagDef tagDef = new TagDef(); tagDef.setTagName(tagName); tagDefRepository.save(tagDef); diff --git a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefCreateRequest.java b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefCreateRequest.java new file mode 100644 index 00000000..1cbb3085 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefCreateRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.TagDef; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Data +@RequiredArgsConstructor +@AllArgsConstructor +public class TagDefCreateRequest { + private String tagName; +} diff --git a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java new file mode 100644 index 00000000..b4836f28 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.TagDef; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TagDefDeleteRequest { + private Long id; +} diff --git a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java index 70483a2f..b04bc677 100644 --- a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java @@ -6,4 +6,5 @@ public interface TagDefRepository extends JpaRepository { boolean existsByTagName(String tagName); + } diff --git a/src/main/java/com/scriptopia/demo/service/TagDefService.java b/src/main/java/com/scriptopia/demo/service/TagDefService.java new file mode 100644 index 00000000..5a129c41 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/TagDefService.java @@ -0,0 +1,46 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.TagDef; +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; +import com.scriptopia.demo.repository.TagDefRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class TagDefService { + private final TagDefRepository tagDefRepository; + + @Transactional + public ResponseEntity addTagName(TagDefCreateRequest req, Long id) { + // TODO - 들어온 토큰으로 관리자 인증 해야함 + String tagName = req.getTagName(); + + if(!tagDefRepository.existsByTagName(tagName)) { // 입력된 태그 이미 존재하는지 확인 + TagDef tagDef = new TagDef(); + tagDef.setTagName(tagName); + tagDefRepository.save(tagDef); + + return ResponseEntity.ok(tagDef); + } + + return ResponseEntity.ok("이미 존재하는 태그입니다."); + } + + @Transactional + public ResponseEntity removeTagName(TagDefDeleteRequest req, Long id) { + // TODO - 들어온 토큰으로 관리자 인증 해야함 + + // TODO - 이미 사용중인 태그는 삭제못하도록 막아야함 + + Long ids = req.getId(); + + tagDefRepository.deleteById(ids); + return ResponseEntity.ok("선택하신 태그가 삭제되었습니다."); + } +} From 141d549fb46f4d0c843e5f56ab5d151dcbc749df Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 13:33:53 +0900 Subject: [PATCH 011/527] =?UTF-8?q?feat:=20jwt=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build.gradle b/build.gradle index 3d730214..99956028 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-security' // 🐳 DB 관련 runtimeOnly 'org.postgresql:postgresql' @@ -45,6 +46,12 @@ dependencies { // ✅ 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // jwt 인증 관련 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { From de130afdfec68608b9f0aa04c37a69431f034ef1 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 14:03:42 +0900 Subject: [PATCH 012/527] feat: implement jwtConfig 1. jwtConfig 2. JwtProperties --- build.gradle | 1 + .../demo/ScriptopiaDemoApplication.java | 2 ++ .../com/scriptopia/demo/config/JwtProperties.java | 15 +++++++++++++++ .../com/scriptopia/demo/config/jwtConfig.java | 9 +++++++++ src/main/resources/application.yml | 8 ++++++++ 5 files changed, 35 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/JwtProperties.java create mode 100644 src/main/java/com/scriptopia/demo/config/jwtConfig.java diff --git a/build.gradle b/build.gradle index 99956028..06374eec 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' // 🐳 DB 관련 runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/scriptopia/demo/ScriptopiaDemoApplication.java b/src/main/java/com/scriptopia/demo/ScriptopiaDemoApplication.java index a86fa96c..b0478aee 100644 --- a/src/main/java/com/scriptopia/demo/ScriptopiaDemoApplication.java +++ b/src/main/java/com/scriptopia/demo/ScriptopiaDemoApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class ScriptopiaDemoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/scriptopia/demo/config/JwtProperties.java b/src/main/java/com/scriptopia/demo/config/JwtProperties.java new file mode 100644 index 00000000..b6ce66d5 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/JwtProperties.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.config; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "auth.jwt") +public record JwtProperties( + @NotBlank String issuer, + @Min(60) long accessExpSeconds, + @Min(60) long refreshExpSeconds, + @NotBlank String secret +) {} diff --git a/src/main/java/com/scriptopia/demo/config/jwtConfig.java b/src/main/java/com/scriptopia/demo/config/jwtConfig.java new file mode 100644 index 00000000..850d7709 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/jwtConfig.java @@ -0,0 +1,9 @@ +package com.scriptopia.demo.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class jwtConfig { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1c1221a2..bfaf7fb5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,3 +16,11 @@ spring: data: mongodb: uri: mongodb://root:tiger@localhost:27017/scriptopia_mongo?authSource=admin + +auth: + jwt: + issuer: scriptopia + access-exp-seconds: 1800 + refresh-exp-seconds: 1209600 + secret: ${JWT_SECRET} + From 4cfd85cc4989363b77e08468c6191b705a74d8c4 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 14:29:41 +0900 Subject: [PATCH 013/527] feat: implement JwtKeyFactory jwt Key object, token generation/verification separation --- .../scriptopia/demo/jwt/JwtKeyFactory.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java new file mode 100644 index 00000000..8622ae7d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.jwt; + + +import com.scriptopia.demo.config.JwtProperties; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; + +@Component +public class JwtKeyFactory { + private final JwtProperties props; + + public JwtKeyFactory(final JwtProperties props) {this.props = props;} + + public Key hmacKey(){ + // 256비트로 키를 생성(사용을 provider에서) + return Keys.hmacShaKeyFor(props.secret().getBytes(StandardCharsets.UTF_8)); + } +} From e856e96a1193b6e7ffd18b4ea923942dfb1c2e2b Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 16:03:43 +0900 Subject: [PATCH 014/527] feat: implement JwtProvider with access/resfresh token generation add JwtProvider for token issuance and utility methods --- .../scriptopia/demo/jwt/JwtKeyFactory.java | 2 + .../com/scriptopia/demo/jwt/JwtProvider.java | 89 +++++++++++++++++++ .../scriptopia/demo/jwt/JwtProvidertest.java | 30 +++++++ 3 files changed, 121 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/jwt/JwtProvider.java create mode 100644 src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java index 8622ae7d..65ed953c 100644 --- a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java +++ b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java @@ -18,4 +18,6 @@ public Key hmacKey(){ // 256비트로 키를 생성(사용을 provider에서) return Keys.hmacShaKeyFor(props.secret().getBytes(StandardCharsets.UTF_8)); } + + } diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java b/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java new file mode 100644 index 00000000..2bc68587 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java @@ -0,0 +1,89 @@ +package com.scriptopia.demo.jwt; + +import com.scriptopia.demo.config.JwtProperties; +import io.jsonwebtoken.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties props; + private final JwtKeyFactory keyFactory; + + private Key signingKey() { + return keyFactory.hmacKey(); + } + + public String createAccessToken(Long userId, List roles) { + Instant now = Instant.now(); + return Jwts.builder() + .setIssuer(props.issuer()) + .setSubject(String.valueOf(userId)) + .claim("roles",roles) + .setId(UUID.randomUUID().toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plusSeconds(props.accessExpSeconds()))) + .signWith(signingKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String createrefreshToken(Long userId, String deviceId) { + Instant now = Instant.now(); + return Jwts.builder() + .setIssuer(props.issuer()) + .setSubject(String.valueOf(userId)) + .claim("device",deviceId) + .setId(UUID.randomUUID().toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plusSeconds(props.refreshExpSeconds()))) + .signWith(signingKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Jws parse(String token) throws JwtException { + return Jwts.parserBuilder() + .setSigningKey(signingKey()) + .build() + .parseClaimsJws(token); + } + + public Long getUserId(String token) { + return Long.valueOf(parse(token).getBody().getSubject()); + } + + @SuppressWarnings("unchecked") + public List getRoles(String token) { + return (List) parse(token).getBody().get("roles"); + } + + public String getDeviceId(String token) { + Object v = parse(token).getBody().get("device"); + return v == null ? null : v.toString(); + } + + public String getJti(String token) { + return parse(token).getBody().getId(); + } + + public Date getExpiry(String token) { + return parse(token).getBody().getExpiration(); + } + + public boolean isValid(String token) { + try { + parse(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + +} diff --git a/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java b/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java new file mode 100644 index 00000000..1d86241f --- /dev/null +++ b/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java @@ -0,0 +1,30 @@ +package com.scriptopia.demo.jwt; + +import com.scriptopia.demo.config.JwtProperties; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JwtProvidertest { + + @Test + void issue_and_parse() { + // 가짜 프로퍼티 + var props = new JwtProperties("scriptopia", 1800, 1209600, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@"); // >= 64 bytes + var keyFactory = new JwtKeyFactory(props); + + var provider = new JwtProvider(props, keyFactory); + + String at = provider.createAccessToken(1L, List.of("ROLE_USER")); + assertThat(provider.isValid(at)).isTrue(); + assertThat(provider.getUserId(at)).isEqualTo(1L); + assertThat(provider.getRoles(at)).containsExactly("ROLE_USER"); + System.out.println(at); + String rt = provider.createrefreshToken(1L, "dev-abc"); + System.out.println(rt); + assertThat(provider.isValid(rt)).isTrue(); + assertThat(provider.getDeviceId(rt)).isEqualTo("dev-abc"); + } +} From e7c0fdbd6c2237fcc78fdc5db595c13c53ac64c8 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 16:05:46 +0900 Subject: [PATCH 015/527] fix: fix type in refresh token creation method --- src/main/java/com/scriptopia/demo/jwt/JwtProvider.java | 2 +- src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java b/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java index 2bc68587..77d64c3e 100644 --- a/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java +++ b/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java @@ -35,7 +35,7 @@ public String createAccessToken(Long userId, List roles) { .compact(); } - public String createrefreshToken(Long userId, String deviceId) { + public String createRefreshToken(Long userId, String deviceId) { Instant now = Instant.now(); return Jwts.builder() .setIssuer(props.issuer()) diff --git a/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java b/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java index 1d86241f..3d74579a 100644 --- a/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java +++ b/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java @@ -22,7 +22,7 @@ void issue_and_parse() { assertThat(provider.getUserId(at)).isEqualTo(1L); assertThat(provider.getRoles(at)).containsExactly("ROLE_USER"); System.out.println(at); - String rt = provider.createrefreshToken(1L, "dev-abc"); + String rt = provider.createRefreshToken(1L, "dev-abc"); System.out.println(rt); assertThat(provider.isValid(rt)).isTrue(); assertThat(provider.getDeviceId(rt)).isEqualTo("dev-abc"); From 5a8f562fbb2e087de3bbe124168e0cc01f0a3bc6 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Thu, 21 Aug 2025 16:11:03 +0900 Subject: [PATCH 016/527] feature : game-session save, delete, update, get --- .../controller/GameSessionController.java | 17 ++++++++++-- .../demo/service/GameSessionService.java | 26 ++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 9f358da9..2a0e6b00 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.gamesession.GameSessionRequest; +import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.service.GameSessionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -13,14 +14,26 @@ public class GameSessionController { private final GameSessionService gameSessionService; @PostMapping("/game-session") - public ResponseEntity createGameSession(@RequestBody GameSessionRequest gameSessionRequest) { + public ResponseEntity createGameSession(@RequestHeader("X-User-ID") Long id) { // 게임 세션 정보 저장 - return gameSessionService.saveGameSession(gameSessionRequest); + return gameSessionService.saveGameSession(id); } // 정보 불러오기 + @GetMapping("/game-session") + public ResponseEntity loadGameSession(@RequestHeader("X-User-ID") Long id) { + return gameSessionService.getGameSession(id); + } // 수정 + @PutMapping("/game-session") + public ResponseEntity updateGameSession(@RequestHeader("X-User-ID") Long id) { + return gameSessionService.updateGameSession(id); + } // 삭제 + @DeleteMapping("/game-session") + public void deleteGameSession(@RequestHeader("X-User-ID") Long id) { + gameSessionService.deleteGameSession(id); + } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index db747bff..2b6ab5c0 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.domain.GameSession; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.dto.gamesession.GameSessionRequest; +import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -14,26 +15,33 @@ @RequiredArgsConstructor public class GameSessionService { private final GameSessionRepository gameSessionRepository; - // User Service 리포 가져오기 - // 사용자 인증 부분 필요 + // TODO User Service 리포 가져오기 + // TODO 사용자 인증 부분 필요 + + public ResponseEntity getGameSession(Long id) { + // TODO 토큰을 통한 사용자 인증 구현 + GameSessionResponse gameSessionResponse = new GameSessionResponse(); + return ResponseEntity.ok(gameSessionResponse); + } @Transactional - public ResponseEntity saveGameSession(GameSessionRequest gameSessionRequest) { - // 토큰을 통한 사용자 인증 + public ResponseEntity saveGameSession(Long id) { + // TODO 토큰을 통한 사용자 인증 GameSession gameSession = new GameSession(); return ResponseEntity.ok(gameSessionRepository.save(gameSession)); } @Transactional - public ResponseEntity updateGameSession(GameSessionRequest gameSessionRequest) { - // 토큰을 통한 사용자 인증 + public ResponseEntity updateGameSession(Long id) { + // TODO 토큰을 통한 사용자 인증 GameSession gameSession = new GameSession(); return ResponseEntity.ok(gameSessionRepository.save(gameSession)); } @Transactional - public void deleteGameSession(GameSessionRequest gameSessionRequest) { - // 토큰을 통한 사용자 인증 - // 게임 세션 삭제 + public void deleteGameSession(Long id) { + // TODO 토큰을 통한 사용자 인증 + GameSession gameSession = new GameSession(); + gameSessionRepository.delete(gameSession); } } From 43be88dd324980832e5264702842d819c9568c99 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 16:19:54 +0900 Subject: [PATCH 017/527] feat: add RefreshSession data model --- .../demo/refresh/RefreshSession.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/refresh/RefreshSession.java diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshSession.java b/src/main/java/com/scriptopia/demo/refresh/RefreshSession.java new file mode 100644 index 00000000..6f0569c5 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/refresh/RefreshSession.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.refresh; + +import com.fasterxml.jackson.annotation.JsonInclude; + + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RefreshSession( + Long userId, + String jti, + String tokenHash, // refresh 원문 해시 + String deviceId, + long expEpochSec, + long createdEpochSec, + String ip, + String ua +) { + +} From 4d04cb24486e40096fc5da056b7a603311149ac9 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 16:24:28 +0900 Subject: [PATCH 018/527] feat: add RefreshRepository Interface --- .../demo/refresh/RefreshRepository.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java b/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java new file mode 100644 index 00000000..41089340 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.refresh; + + +import java.util.Optional; + +public interface RefreshRepository { + void save(RefreshSession session); + Optional find(long userId, String jti); + void delete(long userId, String jti); + + Optional findByDevice(long userId, String device); + void deleteByDevice(long userId, String device); + + void deleteAllForUsr(long userId); +} From b4733c01e0d96258aa8a4d5c454bf868ff889b24 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Thu, 21 Aug 2025 16:40:06 +0900 Subject: [PATCH 019/527] .github pull main --- .../com/scriptopia/demo/controller/GameSessionController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 2a0e6b00..46210ec8 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -23,6 +23,7 @@ public ResponseEntity createGameSession(@RequestHeader("X-User-ID") Long id) @GetMapping("/game-session") public ResponseEntity loadGameSession(@RequestHeader("X-User-ID") Long id) { return gameSessionService.getGameSession(id); + } // 수정 From 813ca71c5ed75d0a08c62b4621134b764d815189 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 16:53:14 +0900 Subject: [PATCH 020/527] feat: implement refresh repository --- .../demo/refresh/RedisRefreshRepository.java | 103 ++++++++++++++++++ .../demo/refresh/RefreshRepository.java | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java diff --git a/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java b/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java new file mode 100644 index 00000000..bc33f423 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java @@ -0,0 +1,103 @@ +package com.scriptopia.demo.refresh; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.lang.runtime.ObjectMethods; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class RedisRefreshRepository implements RefreshRepository { + + private final StringRedisTemplate redis; + private final ObjectMapper om; + + private static String kSession(long userId, String jti) { return "rt:%d:%s".formatted(userId, jti); } + private static String kUserIdx(long userId) {return "rt:idx:u:%d".formatted(userId); } + private static String kDeviceIdx(long userId, String deviceId) { return "rt:idx:u:%d:d:%s".formatted(userId, deviceId); } + + @Override + public void save(RefreshSession s) { + long now = Instant.now().getEpochSecond(); + long ttlSec = Math.max(1, s.expEpochSec() - now); + + String sessionKey = kSession(s.userId(), s.jti()); + String userIdxKey = kUserIdx(s.userId()); + + try { + String json = om.writeValueAsString(s); + + // 세션 저장 + TTL + redis.opsForValue().set(sessionKey, json, Duration.ofSeconds(ttlSec)); + + // 유저 인덱스(JTI 모음) 갱신 + redis.opsForSet().add(userIdxKey, s.jti()); + Long currentTtl = redis.getExpire(userIdxKey); + if (currentTtl == null || currentTtl < ttlSec) { + redis.expire(userIdxKey, Duration.ofSeconds(ttlSec)); + } + + // 디바이스 인덱스 + if (s.deviceId() != null) { + String deviceKey = kDeviceIdx(s.userId(), s.deviceId()); + // 같은 디바이스에서 새로 로그인하면 “이전 JTI”는 무의미해지므로 덮어쓰기 + redis.opsForValue().set(deviceKey, s.jti(), Duration.ofSeconds(ttlSec)); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to save refresh session", e); + } + } + + @Override + public Optional find(long userId, String jti) { + String json = redis.opsForValue().get(kSession(userId, jti)); + if (json == null) return Optional.empty(); + try { + return Optional.of(om.readValue(json, RefreshSession.class)); + } catch (Exception e) { + return Optional.empty(); + } + } + + @Override + public void delete(long userId, String jti) { + redis.delete(kSession(userId, jti)); + redis.opsForSet().remove(kUserIdx(userId), jti); + + } + + @Override + public Optional findByDevice(long userId, String deviceId) { + String jti = redis.opsForValue().get(kDeviceIdx(userId, deviceId)); + if (jti == null) return Optional.empty(); + return find(userId, jti); + } + + @Override + public void deleteByDevice(long userId, String deviceId) { + String deviceKey = kDeviceIdx(userId, deviceId); + String jti = redis.opsForValue().get(deviceKey); + if (jti != null) { + delete(userId, jti); + } + redis.delete(deviceKey); + } + + @Override + public void deleteAllForUser(long userId) { + String userIdxKey = kUserIdx(userId); + Set jtIs = redis.opsForSet().members(userIdxKey); + if (jtIs != null) { + for (String jti : jtIs) { + redis.delete(kSession(userId, jti)); + } + } + redis.delete(userIdxKey); + } +} diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java b/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java index 41089340..d8b1768a 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java +++ b/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java @@ -11,5 +11,5 @@ public interface RefreshRepository { Optional findByDevice(long userId, String device); void deleteByDevice(long userId, String device); - void deleteAllForUsr(long userId); + void deleteAllForUser(long userId); } From 0aad8f48c1d0ce7184d9d366a61084a3f827d8e5 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 17:24:42 +0900 Subject: [PATCH 021/527] feat: implement redis refresh repository --- .../demo/config/SecurityConfig.java | 14 ++++ .../demo/refresh/RefreshTokenService.java | 78 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/SecurityConfig.java create mode 100644 src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java new file mode 100644 index 00000000..bb43d974 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java new file mode 100644 index 00000000..ff919d91 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java @@ -0,0 +1,78 @@ +package com.scriptopia.demo.refresh; + +import com.scriptopia.demo.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + private final RefreshRepository refreshRepository; + private final JwtProvider jwt; + private final PasswordEncoder passwordEncoder; // BCryptPasswordEncoder + + public void saveLoginRefresh(Long userId, String refreshToken, String deviceId, String ip, String ua) { + if (deviceId != null) { + refreshRepository.deleteByDevice(userId, deviceId); // 단일/디바이스 정책 + } + var jti = jwt.getJti(refreshToken); + var exp = jwt.getExpiry(refreshToken).toInstant().getEpochSecond(); + + var session = new RefreshSession( + userId, jti, + passwordEncoder.encode(refreshToken), + deviceId, exp, + Instant.now().getEpochSecond(), + ip, ua + ); + refreshRepository.save(session); + } + + public TokenPair rotate(String refreshToken, String expectedDeviceId, List roles) { + var parsed = jwt.parse(refreshToken); // 유효하지 않으면 예외(JwtException) + Long userId = Long.valueOf(parsed.getBody().getSubject()); + String jti = parsed.getBody().getId(); + String deviceInToken = (String) parsed.getBody().get("device"); + + if (expectedDeviceId != null && !expectedDeviceId.equals(deviceInToken)) { + throw new IllegalArgumentException("Device mismatch"); + } + + var saved = refreshRepository.find(userId, jti) + .orElseThrow(() -> new IllegalArgumentException("Refresh not found")); + + // 소유자 확인 + if (!passwordEncoder.matches(refreshToken, saved.tokenHash())) { + throw new IllegalArgumentException("Refresh hash mismatch"); + } + + // 기존 세션 삭제(재사용 차단) + refreshRepository.delete(userId, jti); + + // 새 토큰 발급 + String newAccess = jwt.createAccessToken(userId, roles); + String newRefresh = jwt.createRefreshToken(userId, deviceInToken); + + // 새 세션 저장 + saveLoginRefresh(userId, newRefresh, deviceInToken, saved.ip(), saved.ua()); + + return new TokenPair(newAccess, newRefresh); + } + + public void logout(String refreshToken) { + var parsed = jwt.parse(refreshToken); + long userId = Long.parseLong(parsed.getBody().getSubject()); + String jti = parsed.getBody().getId(); + refreshRepository.delete(userId, jti); + } + + public void logoutAll(Long userId) { + refreshRepository.deleteAllForUser(userId); + } + + public record TokenPair(String accessToken, String refreshToken) {} +} From 0f500d2f02ac5e2e795fcf2a5173fee2e965676c Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 21:22:37 +0900 Subject: [PATCH 022/527] feat: Implement RedisRefreshRepository --- .../scriptopia/demo/jwt/JwtKeyFactory.java | 24 ++++-- .../demo/refresh/RedisRefreshRepository.java | 2 +- .../demo/refresh/RefreshTokenService.java | 35 ++++++--- src/main/resources/application.yml | 2 +- ...Providertest.java => JwtProviderTest.java} | 12 ++- .../demo/jwt/RefreshTokenServiceTest.java | 78 +++++++++++++++++++ 6 files changed, 133 insertions(+), 20 deletions(-) rename src/test/java/com/scriptopia/demo/jwt/{JwtProvidertest.java => JwtProviderTest.java} (78%) create mode 100644 src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java index 65ed953c..6be416a3 100644 --- a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java +++ b/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.jwt; - import com.scriptopia.demo.config.JwtProperties; +import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Component; @@ -11,13 +11,21 @@ @Component public class JwtKeyFactory { private final JwtProperties props; - - public JwtKeyFactory(final JwtProperties props) {this.props = props;} - - public Key hmacKey(){ - // 256비트로 키를 생성(사용을 provider에서) - return Keys.hmacShaKeyFor(props.secret().getBytes(StandardCharsets.UTF_8)); + public JwtKeyFactory(final JwtProperties props) { this.props = props; } + + public Key hmacKey() { + byte[] keyBytes = toKeyBytes(props.secret()); + if (keyBytes.length < 32) { + throw new IllegalStateException(); + } + return Keys.hmacShaKeyFor(keyBytes); } - + private static byte[] toKeyBytes(String secret) { + try { + return Decoders.BASE64.decode(secret); + } catch (IllegalArgumentException notBase64) { + return secret.getBytes(StandardCharsets.UTF_8); + } + } } diff --git a/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java b/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java index bc33f423..139739df 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java +++ b/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java @@ -100,4 +100,4 @@ public void deleteAllForUser(long userId) { } redis.delete(userIdxKey); } -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java index ff919d91..03da6bea 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java +++ b/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java @@ -5,7 +5,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Instant; +import java.util.Base64; import java.util.List; @Service @@ -13,18 +16,21 @@ public class RefreshTokenService { private final RefreshRepository refreshRepository; private final JwtProvider jwt; - private final PasswordEncoder passwordEncoder; // BCryptPasswordEncoder + private final PasswordEncoder passwordEncoder; public void saveLoginRefresh(Long userId, String refreshToken, String deviceId, String ip, String ua) { if (deviceId != null) { - refreshRepository.deleteByDevice(userId, deviceId); // 단일/디바이스 정책 + refreshRepository.deleteByDevice(userId, deviceId); } var jti = jwt.getJti(refreshToken); var exp = jwt.getExpiry(refreshToken).toInstant().getEpochSecond(); + String rtHashInput = sha256Base64Url(refreshToken); + var session = new RefreshSession( userId, jti, - passwordEncoder.encode(refreshToken), + + passwordEncoder.encode(rtHashInput), deviceId, exp, Instant.now().getEpochSecond(), ip, ua @@ -33,7 +39,7 @@ public void saveLoginRefresh(Long userId, String refreshToken, String deviceId, } public TokenPair rotate(String refreshToken, String expectedDeviceId, List roles) { - var parsed = jwt.parse(refreshToken); // 유효하지 않으면 예외(JwtException) + var parsed = jwt.parse(refreshToken); Long userId = Long.valueOf(parsed.getBody().getSubject()); String jti = parsed.getBody().getId(); String deviceInToken = (String) parsed.getBody().get("device"); @@ -45,19 +51,20 @@ public TokenPair rotate(String refreshToken, String expectedDeviceId, List new IllegalArgumentException("Refresh not found")); - // 소유자 확인 - if (!passwordEncoder.matches(refreshToken, saved.tokenHash())) { + + String input = sha256Base64Url(refreshToken); + if (!passwordEncoder.matches(input, saved.tokenHash())) { throw new IllegalArgumentException("Refresh hash mismatch"); } - // 기존 세션 삭제(재사용 차단) + refreshRepository.delete(userId, jti); - // 새 토큰 발급 + String newAccess = jwt.createAccessToken(userId, roles); String newRefresh = jwt.createRefreshToken(userId, deviceInToken); - // 새 세션 저장 + saveLoginRefresh(userId, newRefresh, deviceInToken, saved.ip(), saved.ua()); return new TokenPair(newAccess, newRefresh); @@ -75,4 +82,14 @@ public void logoutAll(Long userId) { } public record TokenPair(String accessToken, String refreshToken) {} + + private static String sha256Base64Url(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception e) { + throw new IllegalStateException("Hashing failed", e); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bfaf7fb5..a031f183 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,5 +22,5 @@ auth: issuer: scriptopia access-exp-seconds: 1800 refresh-exp-seconds: 1209600 - secret: ${JWT_SECRET} + secret: "${JWT_SECRET}" diff --git a/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java b/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java similarity index 78% rename from src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java rename to src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java index 3d74579a..e793675f 100644 --- a/src/test/java/com/scriptopia/demo/jwt/JwtProvidertest.java +++ b/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java @@ -2,12 +2,18 @@ import com.scriptopia.demo.config.JwtProperties; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -public class JwtProvidertest { +@SpringBootTest +public class JwtProviderTest { + + @Autowired + JwtProperties props; @Test void issue_and_parse() { @@ -27,4 +33,8 @@ void issue_and_parse() { assertThat(provider.isValid(rt)).isTrue(); assertThat(provider.getDeviceId(rt)).isEqualTo("dev-abc"); } + @Test + void test() { + System.out.println(props.secret()); + } } diff --git a/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java new file mode 100644 index 00000000..ddd9863b --- /dev/null +++ b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java @@ -0,0 +1,78 @@ +package com.scriptopia.demo.jwt; + +import com.scriptopia.demo.config.JwtProperties; +import com.scriptopia.demo.refresh.RedisRefreshRepository; +import com.scriptopia.demo.refresh.RefreshRepository; +import com.scriptopia.demo.refresh.RefreshTokenService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class RefreshTokenServiceTest { + + @Autowired StringRedisTemplate redis; + @Autowired JwtProperties props; + @Autowired PasswordEncoder passwordEncoder; + + JwtProvider jwt; + RefreshTokenService refreshSvc; + RefreshRepository refreshRepository; + + @BeforeEach + void setup() { + // 테스트마다 Redis DB 비우기 + var conn = redis.getConnectionFactory().getConnection(); + conn.serverCommands().flushDb(); + + // 실제 구성요소 생성 (프로덕션 코드와 동일) + var keyFactory = new JwtKeyFactory(props); + this.jwt = new JwtProvider(props, keyFactory); + this.refreshRepository = new RedisRefreshRepository(redis, new com.fasterxml.jackson.databind.ObjectMapper()); + this.refreshSvc = new RefreshTokenService(refreshRepository, jwt, passwordEncoder); + } + + @Test + void login_save_rotate_logout_flow() { + long userId = 2L; + String deviceId = "dev-win"; + List roles = List.of("ROLE_USER"); + + // 로그인: RT 발급 + 저장 + String rt1 = jwt.createRefreshToken(userId, deviceId); + refreshSvc.saveLoginRefresh(userId, rt1, deviceId, "127.0.0.1", "JUnit"); + + // 저장 확인 + var jti1 = jwt.getJti(rt1); + assertThat(refreshRepository.find(userId, jti1)).isPresent(); + + // 회전: 기존 RT로 새 AT/RT 발급 → 기존 세션 삭제 + var pair = refreshSvc.rotate(rt1, deviceId, roles); + assertThat(pair.accessToken()).isNotBlank(); + assertThat(pair.refreshToken()).isNotBlank(); + + // 기존 세션은 삭제확인 + assertThat(refreshRepository.find(userId, jti1)).isEmpty(); + + // 새 세션 저장 확인 + var rt2 = pair.refreshToken(); + var jti2 = jwt.getJti(rt2); + assertThat(refreshRepository.find(userId, jti2)).isPresent(); + + assertThatThrownBy(() -> refreshSvc.rotate(rt1, deviceId, roles)) + .isInstanceOf(IllegalArgumentException.class); + + // 로그아웃: 현재 RT 삭제 + refreshSvc.logout(rt2); + assertThat(refreshRepository.find(userId, jti2)).isEmpty(); + } +} From bb3d6695ae2df136fa90d2c16d502530101f03fa Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 21 Aug 2025 21:31:28 +0900 Subject: [PATCH 023/527] feat: create auction item function refator: casecade itemDef to itemEffect feat: devlop dto to acutionResponse for json dependency --- .../demo/controller/AuctionController.java | 23 +++- .../demo/controller/ItemController.java | 14 ++- .../com/scriptopia/demo/domain/ItemDef.java | 6 + .../scriptopia/demo/domain/ItemEffect.java | 10 +- .../java/com/scriptopia/demo/domain/User.java | 7 +- .../com/scriptopia/demo/domain/UserItem.java | 2 + .../demo/dto/auction/AuctionItemResponse.java | 50 ++++++++ .../demo/dto/auction/AuctionRequest.java | 3 +- .../demo/dto/auction/TradeFilterRequest.java | 23 ++++ .../demo/dto/auction/TradeResponse.java | 19 +++ .../demo/dto/devlop/ItemDefResponse.java | 26 ++++ .../demo/dto/devlop/ItemEffectResponse.java | 10 ++ .../demo/dto/items/ItemEffectRequest.java | 1 - .../demo/repository/AuctionRepository.java | 59 ++++++++- .../demo/service/AuctionService.java | 119 +++++++++++++++++- .../demo/service/ItemDefService.java | 70 ++++++++--- 16 files changed, 399 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 539aa771..cdce2c8b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,5 +1,9 @@ package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.auction.TradeResponse; +import com.scriptopia.demo.dto.auction.TradeFilterRequest; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -12,11 +16,22 @@ public class AuctionController { private final AuctionService auctionService; + @PostMapping - public ResponseEntity createAuction( - @RequestBody com.scriptopia.demo.dto.auction.AuctionRequest requestDto, - @RequestHeader("token") String userId) { // 헤더에서 userId 가져오기 임시임 + public ResponseEntity createAuction(@RequestBody AuctionRequest dto, + @RequestHeader("token") String userId ){ // 헤더에서 userId 가져오기 임시임 + + return ResponseEntity.ok(auctionService.createAuction(dto, userId)); + } + + + @GetMapping + public ResponseEntity getTrades( + @RequestBody TradeFilterRequest requestDto) { + + TradeResponse response = auctionService.getTrades(requestDto); + return ResponseEntity.ok(response); - return ResponseEntity.ok(auctionService.createAuction(requestDto, userId)); } + } diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index e8e64a60..edb37bb8 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,14 +1,14 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.devlop.ItemDefResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.service.AuctionService; import com.scriptopia.demo.service.ItemDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/items") @@ -18,10 +18,12 @@ public class ItemController { private final ItemDefService itemDefService; @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { - ItemDef savedItem = itemDefService.createItem(dto); + public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { + ItemDefResponse savedItem = itemDefService.createItem(dto); return ResponseEntity.ok(savedItem); } + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index 4003c9eb..36777c3f 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -5,6 +5,8 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -39,4 +41,8 @@ public class ItemDef { private LocalDateTime createdAt; private Long price; + + @OneToMany(mappedBy = "itemDef", cascade = CascadeType.ALL, orphanRemoval = true) + private List itemEffects = new ArrayList<>(); + } diff --git a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java index ebae0e0e..61138179 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java @@ -10,18 +10,20 @@ public class ItemEffect { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue private Long id; // FK: ItemDefs @ManyToOne(fetch = FetchType.LAZY) - private ItemDef itemDefs; + private ItemDef itemDef; // FK: EffectGradeDef @ManyToOne(fetch = FetchType.LAZY) private EffectGradeDef effectGradeDef; - // 예를 들어 효과 이름이나 수치 같은 필드가 있다면 여기에 추가 + // 예를 들어 효과 이름 private String effectName; - private int effectValue; + + // 아이템 효과 설명 + private String effect_description; } diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index ade7ef94..5ada7ae0 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -1,9 +1,6 @@ package com.scriptopia.demo.domain; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -23,6 +20,8 @@ public class User { private LocalDateTime lastLoginAt; private String profileImgUrl; + + @Enumerated(EnumType.STRING) private Role role; } diff --git a/src/main/java/com/scriptopia/demo/domain/UserItem.java b/src/main/java/com/scriptopia/demo/domain/UserItem.java index 9559507e..4e10e176 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserItem.java +++ b/src/main/java/com/scriptopia/demo/domain/UserItem.java @@ -23,5 +23,7 @@ public class UserItem { private int remainingUses; + + @Enumerated(EnumType.STRING) private TradeStatus tradeStatus; } diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java new file mode 100644 index 00000000..424e708d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java @@ -0,0 +1,50 @@ +package com.scriptopia.demo.dto.auction; + +import com.scriptopia.demo.domain.TradeStatus; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + + +@Data +public class AuctionItemResponse { + + private Long auctionId; + private Long price; + private LocalDateTime createdAt; + + private UserDto seller; + private ItemDto item; + + @Data + public static class UserDto { + private Long userId; + private String nickname; + } + + @Data + public static class ItemDto { + private Long userItemId; + private Long itemDefId; + private String name; + private String description; + private String picSrc; + private int remainingUses; + private TradeStatus tradeStatus; + private String grade; + private int baseStat; + private int strength; + private int agility; + private int intelligence; + private int luck; + private List effects; + } + + @Data + public static class ItemEffectDto { + private String effectName; + private String effectDescription; + private String grade; + } +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java index 78c7437f..91327c4b 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java @@ -6,7 +6,6 @@ @Data public class AuctionRequest { - private String itemDefsId; - private TradeStatus tradeStatus; // ENUM이면 String으로 받아서 변환 + private String itemDefId; // 단수형으로 바꿔주세요 private Long price; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java new file mode 100644 index 00000000..4d210513 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java @@ -0,0 +1,23 @@ +package com.scriptopia.demo.dto.auction; + +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.MainStat; +import lombok.Data; + +import java.util.List; + +@Data +public class TradeFilterRequest { + + private Long pageIndex; // 0부터 시작 + private Long pageSize; // 한 페이지당 아이템 수 + + private String itemName; // 판매 아이템 이름 + private ItemType category; // 아이템 분류 (WEAPON, ARMOR, ARTIFACT, POTION) + private Long minPrice; // 최소 가격 (nullable) + private Long maxPrice; // 최대 가격 (nullable) + private Grade grade; // 아이템 등급 (nullable) + private List effectGrades; // 아이템 효과 등급 필터 (nullable) + private MainStat mainStat; // 주 스탯 (nullable) +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java new file mode 100644 index 00000000..69616b7c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java @@ -0,0 +1,19 @@ +// PagedAuctionResponse.java +package com.scriptopia.demo.dto.auction; + +import lombok.Data; +import java.util.List; + +@Data +public class TradeResponse { + private List content; + private PageInfo pageInfo; + + @Data + public static class PageInfo { + private int currentPage; + private int pageSize; + private long totalItems; + private int totalPages; + } +} diff --git a/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java b/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java new file mode 100644 index 00000000..650fe8cd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.dto.devlop; + + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class ItemDefResponse { + private Long id; + private String name; + private String description; + private String picSrc; + private String itemType; // enum 대신 String으로 전달 + private String mainStat; // enum 대신 String + private Integer baseStat; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private Long price; + private LocalDateTime createdAt; + + private List effects; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java b/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java new file mode 100644 index 00000000..180f65c7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java @@ -0,0 +1,10 @@ +package com.scriptopia.demo.dto.devlop; + +import lombok.Data; + +@Data +public class ItemEffectResponse { + private String effectName; + private String effectDescription; + private String grade; // enum 대신 String +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java index ca4cf982..aba3d1c3 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java @@ -8,5 +8,4 @@ public class ItemEffectRequest { private String effectName; private String effectDescription; private Grade grade; - private Integer effectValue; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 71ae01a0..9d58bd3b 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -1,9 +1,64 @@ package com.scriptopia.demo.repository; -import com.scriptopia.demo.domain.Auction; -import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.domain.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface AuctionRepository extends JpaRepository { boolean existsByUserItem(UserItem userItem); + + + // itemName 우선 검색 (연관 테이블 조인) + @Query(""" + SELECT DISTINCT a FROM Auction a + JOIN FETCH a.userItem ui + JOIN FETCH ui.user u + JOIN FETCH ui.itemDef idf + LEFT JOIN FETCH idf.itemEffects ie + LEFT JOIN FETCH ie.effectGradeDef e + WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) + ORDER BY a.createdAt DESC + """) + Page findByItemName(@Param("itemName") String itemName, Pageable pageable); + + + // 필터 조건 검색 (itemName 없는 경우) + @Query(""" + SELECT DISTINCT a + FROM Auction a + JOIN a.userItem ui + JOIN ui.itemDef id + LEFT JOIN id.itemEffects ie + WHERE (:category IS NULL OR id.itemType = :category) + AND (:grade IS NULL OR id.itemGradeDef.grade = :grade) + AND (:minPrice IS NULL OR a.price >= :minPrice) + AND (:maxPrice IS NULL OR a.price <= :maxPrice) + AND (:mainStat IS NULL OR id.mainStat = :mainStat) + AND ( + :effectGrades IS NULL + OR EXISTS ( + SELECT 1 FROM ItemEffect ie2 + WHERE ie2.itemDef = id + AND ie2.effectGradeDef.grade IN :effectGrades + ) + ) +""") + Page findByFilters( + @Param("category") ItemType category, + @Param("grade") Grade grade, + @Param("minPrice") Long minPrice, + @Param("maxPrice") Long maxPrice, + @Param("mainStat") MainStat mainStat, + @Param("effectGrades") List effectGrades, + Pageable pageable + ); + + + } diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 6ffa120c..f2c2d1a4 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -4,16 +4,27 @@ import com.scriptopia.demo.domain.TradeStatus; import com.scriptopia.demo.domain.UserItem; import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.auction.AuctionItemResponse; +import com.scriptopia.demo.dto.auction.TradeResponse; +import com.scriptopia.demo.dto.auction.TradeFilterRequest; import com.scriptopia.demo.repository.AuctionRepository; import com.scriptopia.demo.repository.UserItemRepository; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + import java.time.LocalDateTime; +import java.util.List; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class AuctionService { private final AuctionRepository auctionRepository; @@ -25,7 +36,7 @@ public String createAuction(AuctionRequest requestDto, String userId) { // UUID(String) → Long 변환 (임시) long userItemId; try { - userItemId = Long.parseLong(requestDto.getItemDefsId()); + userItemId = Long.parseLong(requestDto.getItemDefId()); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid UserItem UUID"); } @@ -63,4 +74,108 @@ public String createAuction(AuctionRequest requestDto, String userId) { return "등록 완료: 가격=" + requestDto.getPrice(); } + + + + + public TradeResponse getTrades(TradeFilterRequest request){ + int page = request.getPageIndex().intValue(); + int size = request.getPageSize().intValue(); + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page auctionPage; + if (request.getItemName() != null && !request.getItemName().isEmpty()) { + // itemName으로 검색, 연관 테이블까지 fetch + auctionPage = auctionRepository.findByItemName(request.getItemName(), pageable); + } else { + // 이름이 없으면 다른 필터 조건으로 검색 + auctionPage = auctionRepository.findByFilters( + request.getCategory(), + request.getGrade(), + request.getMinPrice(), + request.getMaxPrice(), + request.getMainStat(), + request.getEffectGrades(), + pageable + ); + } + + + + List items = auctionPage.stream() + .map(a -> { + AuctionItemResponse dto = new AuctionItemResponse(); + dto.setAuctionId(a.getId()); + dto.setPrice(a.getPrice()); + dto.setCreatedAt(a.getCreatedAt()); + + // seller 정보 + AuctionItemResponse.UserDto userDto = new AuctionItemResponse.UserDto(); + userDto.setUserId(a.getUserItem().getUser().getId()); + userDto.setNickname(a.getUserItem().getUser().getNickname()); + dto.setSeller(userDto); + + // item 정보 + AuctionItemResponse.ItemDto itemDto = new AuctionItemResponse.ItemDto(); + itemDto.setUserItemId(a.getUserItem().getId()); + itemDto.setItemDefId(a.getUserItem().getItemDef().getId()); + itemDto.setName(a.getUserItem().getItemDef().getName()); + itemDto.setDescription(a.getUserItem().getItemDef().getDescription()); + itemDto.setPicSrc(a.getUserItem().getItemDef().getPicSrc()); + itemDto.setRemainingUses(a.getUserItem().getRemainingUses()); + itemDto.setTradeStatus(a.getUserItem().getTradeStatus()); + itemDto.setGrade(String.valueOf(a.getUserItem().getItemDef().getItemGradeDef().getGrade())); + itemDto.setBaseStat(a.getUserItem().getItemDef().getBaseStat()); + itemDto.setStrength(a.getUserItem().getItemDef().getStrength()); + itemDto.setAgility(a.getUserItem().getItemDef().getAgility()); + itemDto.setIntelligence(a.getUserItem().getItemDef().getIntelligence()); + itemDto.setLuck(a.getUserItem().getItemDef().getLuck()); + + // 효과 리스트 → 조건과 관계없이 "모든 효과"를 포함 + // (JPQL에서 한 개만 남았더라도, Hibernate 엔티티를 통해 전체 컬렉션 다시 로딩) + a.getUserItem().getItemDef().getItemEffects().size(); // lazy 초기화 강제 + + List effects = + a.getUserItem().getItemDef().getItemEffects().stream() + .map(e -> { + AuctionItemResponse.ItemEffectDto effDto = new AuctionItemResponse.ItemEffectDto(); + effDto.setEffectName(e.getEffectName()); + effDto.setEffectDescription(e.getEffect_description()); + effDto.setGrade(e.getEffectGradeDef().getGrade().name()); + return effDto; + }) + .toList(); + + itemDto.setEffects(effects); + dto.setItem(itemDto); + + return dto; + }) + .toList(); + + + TradeResponse response = new TradeResponse(); + response.setContent(items); + + TradeResponse.PageInfo pageInfo = new TradeResponse.PageInfo(); + pageInfo.setCurrentPage(auctionPage.getNumber()); // 현재 페이지 + pageInfo.setPageSize(auctionPage.getSize()); // 페이지 당 항목 수 + pageInfo.setTotalPages(auctionPage.getTotalPages()); // 전체 페이지 수 + pageInfo.setTotalItems(auctionPage.getTotalElements()); // 전체 아이템 수 + + response.setPageInfo(pageInfo); + + return response; + + + } + + + + + + + + + } diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index e65eed7c..caac108f 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,39 +1,39 @@ package com.scriptopia.demo.service; - import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.domain.ItemEffect; import com.scriptopia.demo.domain.ItemGradeDef; -import com.scriptopia.demo.dto.items.ItemDefRequest; -import com.scriptopia.demo.dto.items.ItemEffectRequest; +import com.scriptopia.demo.dto.devlop.ItemDefResponse; +import com.scriptopia.demo.dto.devlop.ItemEffectResponse; +import com.scriptopia.demo.dto.items.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; -import com.scriptopia.demo.repository.ItemEffectRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor -@Transactional +@Transactional(readOnly = true) public class ItemDefService { private final ItemDefRepository itemDefRepository; - private final ItemEffectRepository itemEffectRepository; private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; @Transactional - public ItemDef createItem(ItemDefRequest dto) { - // 1️⃣ ItemGradeDef 조회 + public ItemDefResponse createItem(ItemDefRequest dto) { + // ItemGradeDef 조회 ItemGradeDef gradeDef = itemGradeDefRepository.findById(dto.getItemGradeDefId()) .orElseThrow(() -> new IllegalArgumentException("ItemGradeDef not found")); - // 2️⃣ ItemDef 생성 + // ItemDef 생성 ItemDef itemDef = new ItemDef(); itemDef.setName(dto.getName()); itemDef.setDescription(dto.getDescription()); @@ -49,24 +49,58 @@ public ItemDef createItem(ItemDefRequest dto) { itemDef.setItemGradeDef(gradeDef); itemDef.setCreatedAt(LocalDateTime.now()); - itemDefRepository.save(itemDef); - - // 3️⃣ ItemEffect 생성 + // ItemEffect 생성 if (dto.getEffects() != null) { for (ItemEffectRequest effectDto : dto.getEffects()) { EffectGradeDef effectGradeDef = effectGradeDefRepository.findById(effectDto.getGrade().ordinal() + 1L) .orElseThrow(() -> new IllegalArgumentException("EffectGradeDef not found")); ItemEffect effect = new ItemEffect(); - effect.setItemDefs(itemDef); + effect.setItemDef(itemDef); effect.setEffectGradeDef(effectGradeDef); effect.setEffectName(effectDto.getEffectName()); - effect.setEffectValue(effectDto.getEffectValue()); + effect.setEffect_description(effectDto.getEffectDescription()); - itemEffectRepository.save(effect); + itemDef.getItemEffects().add(effect); } } - return itemDef; + // ItemDef 저장 (cascade로 ItemEffect도 같이 저장) + itemDefRepository.save(itemDef); + + // DTO 변환 후 반환 + return toResponse(itemDef); + } + + // ================== DTO 변환 ================== + private ItemDefResponse toResponse(ItemDef itemDef) { + ItemDefResponse response = new ItemDefResponse(); + response.setId(itemDef.getId()); + response.setName(itemDef.getName()); + response.setDescription(itemDef.getDescription()); + response.setPicSrc(itemDef.getPicSrc()); + response.setItemType(itemDef.getItemType().name()); + response.setMainStat(itemDef.getMainStat().name()); + response.setBaseStat(itemDef.getBaseStat()); + response.setStrength(itemDef.getStrength()); + response.setAgility(itemDef.getAgility()); + response.setIntelligence(itemDef.getIntelligence()); + response.setLuck(itemDef.getLuck()); + response.setPrice(itemDef.getPrice()); + response.setCreatedAt(itemDef.getCreatedAt()); + + List effects = itemDef.getItemEffects().stream() + .map(effect -> { + ItemEffectResponse eResp = new ItemEffectResponse(); + eResp.setEffectName(effect.getEffectName()); + eResp.setEffectDescription(effect.getEffect_description()); + eResp.setGrade(effect.getEffectGradeDef().getGrade().name()); + return eResp; + }) + .collect(Collectors.toList()); + + response.setEffects(effects); + + return response; } -} \ No newline at end of file +} From 56637a1f2ae4dfe0ba0806eaf8de39a7457f8a74 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 21:35:01 +0900 Subject: [PATCH 024/527] feat: add dto for jwt LoginRequest RefreshRequest TokenResponse --- .../com/scriptopia/demo/TagDefController.java | 2 +- .../demo/controller/AuctionController.java | 2 +- .../demo/controller/ItemController.java | 2 +- .../scriptopia/demo/dto/user/LoginRequest.java | 17 +++++++++++++++++ .../demo/dto/user/RefreshRequest.java | 13 +++++++++++++ .../scriptopia/demo/dto/user/TokenResponse.java | 13 +++++++++++++ .../{refresh => record}/RefreshSession.java | 2 +- .../RedisRefreshRepository.java | 4 ++-- .../RefreshRepository.java | 4 +++- .../demo/{jwt => utils}/JwtKeyFactory.java | 2 +- .../demo/{jwt => utils}/JwtProvider.java | 2 +- .../{ => utils}/service/AuctionService.java | 2 +- .../{ => utils}/service/ItemDefService.java | 2 +- .../service}/RefreshTokenService.java | 6 ++++-- .../demo/{ => utils}/service/TagDefService.java | 2 +- .../scriptopia/demo/jwt/JwtProviderTest.java | 2 ++ .../demo/jwt/RefreshTokenServiceTest.java | 8 +++++--- 17 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java rename src/main/java/com/scriptopia/demo/{refresh => record}/RefreshSession.java (90%) rename src/main/java/com/scriptopia/demo/{refresh => repository}/RedisRefreshRepository.java (97%) rename src/main/java/com/scriptopia/demo/{refresh => repository}/RefreshRepository.java (80%) rename src/main/java/com/scriptopia/demo/{jwt => utils}/JwtKeyFactory.java (96%) rename src/main/java/com/scriptopia/demo/{jwt => utils}/JwtProvider.java (98%) rename src/main/java/com/scriptopia/demo/{ => utils}/service/AuctionService.java (98%) rename src/main/java/com/scriptopia/demo/{ => utils}/service/ItemDefService.java (98%) rename src/main/java/com/scriptopia/demo/{refresh => utils/service}/RefreshTokenService.java (94%) rename src/main/java/com/scriptopia/demo/{ => utils}/service/TagDefService.java (97%) diff --git a/src/main/java/com/scriptopia/demo/TagDefController.java b/src/main/java/com/scriptopia/demo/TagDefController.java index c003e339..f95fe7c7 100644 --- a/src/main/java/com/scriptopia/demo/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/TagDefController.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; -import com.scriptopia.demo.service.TagDefService; +import com.scriptopia.demo.utils.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 539aa771..0a09cb20 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,6 +1,6 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.service.AuctionService; +import com.scriptopia.demo.utils.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index e8e64a60..4cbdda41 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.dto.items.ItemDefRequest; -import com.scriptopia.demo.service.ItemDefService; +import com.scriptopia.demo.utils.service.ItemDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; diff --git a/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java new file mode 100644 index 00000000..bd1581e0 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LoginRequest { + private String email; + private String password; + private String deviceId; + +} diff --git a/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java b/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java new file mode 100644 index 00000000..7cfc8007 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RefreshRequest { + private String refreshToken; + private String deviceId; +} diff --git a/src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java b/src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java new file mode 100644 index 00000000..6fca7c5b --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshSession.java b/src/main/java/com/scriptopia/demo/record/RefreshSession.java similarity index 90% rename from src/main/java/com/scriptopia/demo/refresh/RefreshSession.java rename to src/main/java/com/scriptopia/demo/record/RefreshSession.java index 6f0569c5..a7d44c4d 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RefreshSession.java +++ b/src/main/java/com/scriptopia/demo/record/RefreshSession.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.refresh; +package com.scriptopia.demo.record; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java b/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java similarity index 97% rename from src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java rename to src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java index 139739df..6e2e1765 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RedisRefreshRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java @@ -1,11 +1,11 @@ -package com.scriptopia.demo.refresh; +package com.scriptopia.demo.repository; import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.record.RefreshSession; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; -import java.lang.runtime.ObjectMethods; import java.time.Duration; import java.time.Instant; import java.util.Optional; diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java b/src/main/java/com/scriptopia/demo/repository/RefreshRepository.java similarity index 80% rename from src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java rename to src/main/java/com/scriptopia/demo/repository/RefreshRepository.java index d8b1768a..a729e900 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RefreshRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/RefreshRepository.java @@ -1,6 +1,8 @@ -package com.scriptopia.demo.refresh; +package com.scriptopia.demo.repository; +import com.scriptopia.demo.record.RefreshSession; + import java.util.Optional; public interface RefreshRepository { diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java b/src/main/java/com/scriptopia/demo/utils/JwtKeyFactory.java similarity index 96% rename from src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java rename to src/main/java/com/scriptopia/demo/utils/JwtKeyFactory.java index 6be416a3..64859da1 100644 --- a/src/main/java/com/scriptopia/demo/jwt/JwtKeyFactory.java +++ b/src/main/java/com/scriptopia/demo/utils/JwtKeyFactory.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.jwt; +package com.scriptopia.demo.utils; import com.scriptopia.demo.config.JwtProperties; import io.jsonwebtoken.io.Decoders; diff --git a/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java b/src/main/java/com/scriptopia/demo/utils/JwtProvider.java similarity index 98% rename from src/main/java/com/scriptopia/demo/jwt/JwtProvider.java rename to src/main/java/com/scriptopia/demo/utils/JwtProvider.java index 77d64c3e..25c4f656 100644 --- a/src/main/java/com/scriptopia/demo/jwt/JwtProvider.java +++ b/src/main/java/com/scriptopia/demo/utils/JwtProvider.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.jwt; +package com.scriptopia.demo.utils; import com.scriptopia.demo.config.JwtProperties; import io.jsonwebtoken.*; diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java similarity index 98% rename from src/main/java/com/scriptopia/demo/service/AuctionService.java rename to src/main/java/com/scriptopia/demo/utils/service/AuctionService.java index 6ffa120c..03dce1db 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.service; +package com.scriptopia.demo.utils.service; import com.scriptopia.demo.domain.Auction; import com.scriptopia.demo.domain.TradeStatus; diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java similarity index 98% rename from src/main/java/com/scriptopia/demo/service/ItemDefService.java rename to src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java index e65eed7c..6cdff11d 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.service; +package com.scriptopia.demo.utils.service; import com.scriptopia.demo.domain.EffectGradeDef; diff --git a/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/utils/service/RefreshTokenService.java similarity index 94% rename from src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java rename to src/main/java/com/scriptopia/demo/utils/service/RefreshTokenService.java index 03da6bea..22fd477c 100644 --- a/src/main/java/com/scriptopia/demo/refresh/RefreshTokenService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/RefreshTokenService.java @@ -1,6 +1,8 @@ -package com.scriptopia.demo.refresh; +package com.scriptopia.demo.utils.service; -import com.scriptopia.demo.jwt.JwtProvider; +import com.scriptopia.demo.utils.JwtProvider; +import com.scriptopia.demo.record.RefreshSession; +import com.scriptopia.demo.repository.RefreshRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/scriptopia/demo/service/TagDefService.java b/src/main/java/com/scriptopia/demo/utils/service/TagDefService.java similarity index 97% rename from src/main/java/com/scriptopia/demo/service/TagDefService.java rename to src/main/java/com/scriptopia/demo/utils/service/TagDefService.java index 5a129c41..86f20e0b 100644 --- a/src/main/java/com/scriptopia/demo/service/TagDefService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/TagDefService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.service; +package com.scriptopia.demo.utils.service; import com.scriptopia.demo.domain.TagDef; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; diff --git a/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java b/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java index e793675f..c86e372a 100644 --- a/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java +++ b/src/test/java/com/scriptopia/demo/jwt/JwtProviderTest.java @@ -1,6 +1,8 @@ package com.scriptopia.demo.jwt; import com.scriptopia.demo.config.JwtProperties; +import com.scriptopia.demo.utils.JwtKeyFactory; +import com.scriptopia.demo.utils.JwtProvider; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java index ddd9863b..3671f202 100644 --- a/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java +++ b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java @@ -1,9 +1,11 @@ package com.scriptopia.demo.jwt; import com.scriptopia.demo.config.JwtProperties; -import com.scriptopia.demo.refresh.RedisRefreshRepository; -import com.scriptopia.demo.refresh.RefreshRepository; -import com.scriptopia.demo.refresh.RefreshTokenService; +import com.scriptopia.demo.repository.RedisRefreshRepository; +import com.scriptopia.demo.repository.RefreshRepository; +import com.scriptopia.demo.utils.service.RefreshTokenService; +import com.scriptopia.demo.utils.JwtKeyFactory; +import com.scriptopia.demo.utils.JwtProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; From adfa959814309f7ea6381e11a6e84d79c1fca693 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 21:42:53 +0900 Subject: [PATCH 025/527] feat: implement JwtAuthFilter --- .../scriptopia/demo/config/JwtAuthFilter.java | 53 +++++++++++++++++++ .../demo/config/SecurityConfig.java | 6 +++ 2 files changed, 59 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java new file mode 100644 index 00000000..950a9069 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -0,0 +1,53 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.utils.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import java.io.IOException; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtProvider jwt; + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + + String uri = req.getRequestURI(); + if (uri.startsWith("/auth/")) { + chain.doFilter(req, res); + return; + } + + String auth = req.getHeader("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) { + String token = auth.substring(7); + try { + jwt.parse(token); // 서명/만료 체크 + + Long userId = jwt.getUserId(token); + var roles = jwt.getRoles(token).stream() + .map(SimpleGrantedAuthority::new).collect(Collectors.toList()); + + var authentication = new UsernamePasswordAuthenticationToken(userId, null, roles); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception ignored) { + // 유효하지 않으면 인증 없음 상태로 계속 진행 → 최종적으로 401 + } + } + chain.doFilter(req, res); + } +} diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index bb43d974..69827240 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,14 +1,20 @@ package com.scriptopia.demo.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + } From 89de37938e2f759e079f8b2da718586466b0e900 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 21:47:07 +0900 Subject: [PATCH 026/527] feat: Implement SecurityConfig --- .../scriptopia/demo/config/JwtAuthFilter.java | 4 +- .../demo/config/SecurityConfig.java | 50 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 950a9069..b94a4d22 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -35,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, if (auth != null && auth.startsWith("Bearer ")) { String token = auth.substring(7); try { - jwt.parse(token); // 서명/만료 체크 + jwt.parse(token); Long userId = jwt.getUserId(token); var roles = jwt.getRoles(token).stream() @@ -45,7 +45,7 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception ignored) { - // 유효하지 않으면 인증 없음 상태로 계속 진행 → 최종적으로 401 + } } chain.doFilter(req, res); diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 69827240..fae6b162 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,20 +1,68 @@ package com.scriptopia.demo.config; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import java.nio.charset.StandardCharsets; +import java.util.List; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + private final JwtAuthFilter jwtAuthFilter; -} + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(req -> { + var c = new CorsConfiguration(); + c.setAllowedOrigins(List.of("http://localhost:3000")); // 현재는 로컬로 해놓고 나중에 바꿔야 댐 + c.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH","OPTIONS")); + c.setAllowedHeaders(List.of("Authorization","Content-Type")); + c.setAllowCredentials(true); + c.setMaxAge(3600L); + return c; + })) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((req, res, e) -> { + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.getOutputStream().write( + "{\"code\":\"AUTH_401\",\"message\":\"Unauthorized\"}" + .getBytes(StandardCharsets.UTF_8)); + }) + .accessDeniedHandler((req, res, e) -> { + res.setStatus(HttpServletResponse.SC_FORBIDDEN); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.getOutputStream().write( + "{\"code\":\"AUTH_403\",\"message\":\"Forbidden\"}" + .getBytes(StandardCharsets.UTF_8)); + }) + ); + return http.build(); + } +} \ No newline at end of file From a0acb37ae031ddba0e10f2d1af05e12f1fc10c22 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 22:04:53 +0900 Subject: [PATCH 027/527] feat: Implement LocalAccountService --- .../demo/controller/AuthController.java | 121 ++++++++++++++++++ .../demo/dto/user/LoginRequest.java | 2 - .../repository/LocalAccountRepository.java | 10 ++ .../demo/repository/UserRepository.java | 2 + .../demo/service/LocalAccountService.java | 44 +++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/controller/AuthController.java create mode 100644 src/main/java/com/scriptopia/demo/service/LocalAccountService.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java new file mode 100644 index 00000000..6c084581 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -0,0 +1,121 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.user.LoginRequest; +import com.scriptopia.demo.dto.user.RefreshRequest; +import com.scriptopia.demo.dto.user.TokenResponse; +import com.scriptopia.demo.service.LocalAccountService; +import com.scriptopia.demo.utils.JwtProvider; +import com.scriptopia.demo.utils.service.RefreshTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.List; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + private final LocalAccountService localAccountService; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwt; + private final RefreshTokenService refreshSvc; + + private static final String RT_COOKIE = "RT"; + private static final boolean COOKIE_SECURE = true; // HTTPS면 true + private static final String COOKIE_SAMESITE = "None"; // 동일 도메인이면 "Lax" + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody @Valid LoginRequest req, + HttpServletRequest request, + HttpServletResponse response + ) { + var user = localAccountService.loadByEmail(req.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("invalid credentials")); + if (!passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) { + throw new IllegalArgumentException("invalid credentials"); + } + + List roles = user.getRoles(); + String access = jwt.createAccessToken(user.getId(), roles); + String refresh = jwt.createRefreshToken(user.getId(), req.getDeviceId()); + + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + refreshSvc.saveLoginRefresh(user.getId(), refresh, req.getDeviceId(), ip, ua); + + // 쿠키에 RT 넣기 + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); + + return ResponseEntity.ok(new TokenResponse(access, null)); + } + + // 쿠키 기반 리프레시(권장) + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(name = RT_COOKIE, required = false) String refreshToken, + @RequestParam(required = false) String deviceId + ) { + if (refreshToken == null || refreshToken.isBlank()) { + return ResponseEntity.status(401).build(); + } + Long userId = jwt.getUserId(refreshToken); + List roles = localAccountService.getRoles(userId); + + var pair = refreshSvc.rotate(refreshToken, deviceId, roles); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) + .body(new TokenResponse(pair.accessToken(), null)); + } + + // 바디 기반 리프레시(옵션: 쿠키 미사용 시) + @PostMapping("/refresh/body") + public ResponseEntity refreshBody(@RequestBody @Valid RefreshRequest req) { + Long userId = jwt.getUserId(req.getRefreshToken()); + List roles = localAccountService.getRoles(userId); + var pair = refreshSvc.rotate(req.getRefreshToken(), req.getDeviceId(), roles); + return ResponseEntity.ok(new TokenResponse(pair.accessToken(), pair.refreshToken())); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue(name = RT_COOKIE, required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken != null && !refreshToken.isBlank()) { + refreshSvc.logout(refreshToken); + } + response.addHeader(HttpHeaders.SET_COOKIE, removeRefreshCookie().toString()); + return ResponseEntity.noContent().build(); + } + + private ResponseCookie refreshCookie(String value) { + // RT 만료기간에 맞춰 maxAge를 조정하고 싶으면 JwtProvider.getExpiry(...)로 계산 가능 + return ResponseCookie.from(RT_COOKIE, value) + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); + } + + private ResponseCookie removeRefreshCookie() { + return ResponseCookie.from(RT_COOKIE, "") + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(0) + .build(); + } +} diff --git a/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java index bd1581e0..32c6fd80 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java @@ -1,7 +1,5 @@ package com.scriptopia.demo.dto.user; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java index 53cb3447..1d30dd05 100644 --- a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java @@ -1,7 +1,17 @@ package com.scriptopia.demo.repository; +import aj.org.objectweb.asm.commons.Remapper; import com.scriptopia.demo.domain.LocalAccount; +import org.springframework.cglib.core.Local; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; public interface LocalAccountRepository extends JpaRepository { + Optional findByEmail(String email); + } + diff --git a/src/main/java/com/scriptopia/demo/repository/UserRepository.java b/src/main/java/com/scriptopia/demo/repository/UserRepository.java index 84b795ef..c4724ec2 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserRepository.java @@ -4,5 +4,7 @@ import com.scriptopia.demo.domain.User; import org.springframework.data.jpa.repository.JpaRepository; + public interface UserRepository extends JpaRepository { + } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java new file mode 100644 index 00000000..edcfb8de --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -0,0 +1,44 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.repository.LocalAccountRepository; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class LocalAccountService { + + private final LocalAccountRepository localAccountRepository; + + /** 이메일로 사용자 + 패스워드 해시 + 롤 조회 */ + public Optional loadByEmail(String email) { + return localAccountRepository.findByEmail(email).map(a -> + new AccountInfo( + a.getId(), + a.getEmail(), + a.getPassword(), // ← DB에 저장된 해시(BCrypt) + List.of("ROLE_USER") // ← 현재는 기본 롤로 처리 + ) + ); + } + + public List getRoles(Long userId) { + return List.of("ROLE_USER"); + } + + @Getter + @AllArgsConstructor + public static class AccountInfo { + private Long id; + private String email; + private String passwordHash; + private List roles; + } +} From 23f1e61fda2a7f5fa55b3602cdf5077595f552e6 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 21 Aug 2025 23:39:27 +0900 Subject: [PATCH 028/527] Implementation of user management-related functions add user-setting entity add user-setting-repository Implement Login in local-account service Implement Register in local-account service WIP: Refresh token expiration verification in progress --- .../demo/controller/AuthController.java | 70 +++----- .../com/scriptopia/demo/domain/FontType.java | 5 + .../com/scriptopia/demo/domain/Theme.java | 5 + .../scriptopia/demo/domain/UserSetting.java | 31 ++++ .../demo/dto/user/RegisterRequest.java | 14 ++ .../repository/LocalAccountRepository.java | 1 + .../demo/repository/UserRepository.java | 1 + .../repository/UserSettingRepository.java | 7 + .../demo/service/LocalAccountService.java | 160 ++++++++++++++++-- 9 files changed, 228 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/domain/FontType.java create mode 100644 src/main/java/com/scriptopia/demo/domain/Theme.java create mode 100644 src/main/java/com/scriptopia/demo/domain/UserSetting.java create mode 100644 src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java create mode 100644 src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 6c084581..f6168a4e 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.user.LoginRequest; -import com.scriptopia.demo.dto.user.RefreshRequest; +import com.scriptopia.demo.dto.user.RegisterRequest; import com.scriptopia.demo.dto.user.TokenResponse; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.utils.JwtProvider; @@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import java.time.Duration; @@ -24,13 +23,23 @@ @RequiredArgsConstructor public class AuthController { private final LocalAccountService localAccountService; - private final PasswordEncoder passwordEncoder; private final JwtProvider jwt; - private final RefreshTokenService refreshSvc; + private final RefreshTokenService refreshTokenService; private static final String RT_COOKIE = "RT"; - private static final boolean COOKIE_SECURE = true; // HTTPS면 true - private static final String COOKIE_SAMESITE = "None"; // 동일 도메인이면 "Lax" + private static final boolean COOKIE_SECURE = true; + private static final String COOKIE_SAMESITE = "None"; + + + + @PostMapping("register") + public ResponseEntity register( + @RequestBody @Valid RegisterRequest registerRequest + ) { + localAccountService.register(registerRequest); + return ResponseEntity.status(201).build(); + + } @PostMapping("/login") public ResponseEntity login( @@ -38,28 +47,11 @@ public ResponseEntity login( HttpServletRequest request, HttpServletResponse response ) { - var user = localAccountService.loadByEmail(req.getEmail()) - .orElseThrow(() -> new IllegalArgumentException("invalid credentials")); - if (!passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) { - throw new IllegalArgumentException("invalid credentials"); - } - - List roles = user.getRoles(); - String access = jwt.createAccessToken(user.getId(), roles); - String refresh = jwt.createRefreshToken(user.getId(), req.getDeviceId()); - - String ip = request.getRemoteAddr(); - String ua = request.getHeader("User-Agent"); - refreshSvc.saveLoginRefresh(user.getId(), refresh, req.getDeviceId(), ip, ua); - - // 쿠키에 RT 넣기 - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); - - return ResponseEntity.ok(new TokenResponse(access, null)); + return ResponseEntity.ok(localAccountService.login(req, request, response)); } - // 쿠키 기반 리프레시(권장) - @PostMapping("/refresh") + // 쿠키 기반 리프레시 + @PostMapping("/token/refresh") public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, @RequestParam(required = false) String deviceId @@ -70,36 +62,26 @@ public ResponseEntity refresh( Long userId = jwt.getUserId(refreshToken); List roles = localAccountService.getRoles(userId); - var pair = refreshSvc.rotate(refreshToken, deviceId, roles); + var pair = refreshTokenService.rotate(refreshToken, deviceId, roles); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) .body(new TokenResponse(pair.accessToken(), null)); } - // 바디 기반 리프레시(옵션: 쿠키 미사용 시) - @PostMapping("/refresh/body") - public ResponseEntity refreshBody(@RequestBody @Valid RefreshRequest req) { - Long userId = jwt.getUserId(req.getRefreshToken()); - List roles = localAccountService.getRoles(userId); - var pair = refreshSvc.rotate(req.getRefreshToken(), req.getDeviceId(), roles); - return ResponseEntity.ok(new TokenResponse(pair.accessToken(), pair.refreshToken())); - } - @PostMapping("/logout") public ResponseEntity logout( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { if (refreshToken != null && !refreshToken.isBlank()) { - refreshSvc.logout(refreshToken); + refreshTokenService.logout(refreshToken); } - response.addHeader(HttpHeaders.SET_COOKIE, removeRefreshCookie().toString()); + response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); return ResponseEntity.noContent().build(); } private ResponseCookie refreshCookie(String value) { - // RT 만료기간에 맞춰 maxAge를 조정하고 싶으면 JwtProvider.getExpiry(...)로 계산 가능 return ResponseCookie.from(RT_COOKIE, value) .httpOnly(true) .secure(COOKIE_SECURE) @@ -109,13 +91,5 @@ private ResponseCookie refreshCookie(String value) { .build(); } - private ResponseCookie removeRefreshCookie() { - return ResponseCookie.from(RT_COOKIE, "") - .httpOnly(true) - .secure(COOKIE_SECURE) - .sameSite(COOKIE_SAMESITE) - .path("/") - .maxAge(0) - .build(); - } + } diff --git a/src/main/java/com/scriptopia/demo/domain/FontType.java b/src/main/java/com/scriptopia/demo/domain/FontType.java new file mode 100644 index 00000000..0b0db9af --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/FontType.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum FontType { + F1,F2, PretendardVariable +} diff --git a/src/main/java/com/scriptopia/demo/domain/Theme.java b/src/main/java/com/scriptopia/demo/domain/Theme.java new file mode 100644 index 00000000..40806439 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/Theme.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum Theme { + LIGHT, DARK +} diff --git a/src/main/java/com/scriptopia/demo/domain/UserSetting.java b/src/main/java/com/scriptopia/demo/domain/UserSetting.java new file mode 100644 index 00000000..d171841a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/UserSetting.java @@ -0,0 +1,31 @@ +package com.scriptopia.demo.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import lombok.Data; + +import java.awt.*; +import java.time.LocalDateTime; + +@Entity +@Data +public class UserSetting { + + @Id@GeneratedValue + private long id; + + @OneToOne + private User user; + + private Theme theme; + + private FontType fontType; + + private int fontSize; + + private int lineHeight; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java new file mode 100644 index 00000000..0172a5f2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RegisterRequest { + private String userEmail; + private String password; + private String nickname; +} diff --git a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java index 1d30dd05..5657aabf 100644 --- a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java @@ -13,5 +13,6 @@ public interface LocalAccountRepository extends JpaRepository { Optional findByEmail(String email); + boolean existsByEmail(String email); } diff --git a/src/main/java/com/scriptopia/demo/repository/UserRepository.java b/src/main/java/com/scriptopia/demo/repository/UserRepository.java index c4724ec2..0f9ded90 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserRepository.java @@ -7,4 +7,5 @@ public interface UserRepository extends JpaRepository { + boolean existsByNickname(String nickname); } diff --git a/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java new file mode 100644 index 00000000..9f36d364 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.UserSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserSettingRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index edcfb8de..a967e0f5 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -1,13 +1,28 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.dto.user.LoginRequest; +import com.scriptopia.demo.dto.user.RegisterRequest; +import com.scriptopia.demo.dto.user.TokenResponse; import com.scriptopia.demo.repository.LocalAccountRepository; +import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.utils.JwtProvider; +import com.scriptopia.demo.utils.service.RefreshTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; +import java.util.Locale; import java.util.Optional; @Service @@ -16,29 +31,138 @@ public class LocalAccountService { private final LocalAccountRepository localAccountRepository; + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + private final JwtProvider jwt; + private final RefreshTokenService refreshService; + + private static final String RT_COOKIE = "RT"; + private static final boolean COOKIE_SECURE = true; + private static final String COOKIE_SAMESITE = "None"; + + @Transactional + public void register(RegisterRequest request) { + String normalizedEmail = normalizeEmail(request.getUserEmail()); + validateParams(normalizedEmail, request.getPassword(), request.getNickname()); + isAvailable(normalizedEmail, request.getNickname()); + + //user 객체 생성 + User user = new User(); + user.setNickname(request.getNickname()); + user.setPia(0L); + user.setCreatedAt(LocalDateTime.now()); + user.setLastLoginAt(null); + user.setProfileImgUrl(null); + user.setRole(Role.USER); + userRepository.save(user); + + //localAccount 객체 생성 + LocalAccount localAccount = new LocalAccount(); + localAccount.setUser(user); + localAccount.setEmail(normalizedEmail); + localAccount.setPassword(passwordEncoder.encode(request.getPassword())); + localAccount.setUpdatedAt(LocalDateTime.now()); + localAccountRepository.save(localAccount); + + //환경 설정 초기 값 + UserSetting userSetting = new UserSetting(); + userSetting.setTheme(Theme.DARK); + userSetting.setFontType(FontType.PretendardVariable); + userSetting.setFontSize(16); + userSetting.setLineHeight(1); + userSetting.setUpdatedAt(LocalDateTime.now()); - /** 이메일로 사용자 + 패스워드 해시 + 롤 조회 */ - public Optional loadByEmail(String email) { - return localAccountRepository.findByEmail(email).map(a -> - new AccountInfo( - a.getId(), - a.getEmail(), - a.getPassword(), // ← DB에 저장된 해시(BCrypt) - List.of("ROLE_USER") // ← 현재는 기본 롤로 처리 - ) - ); } + @Transactional + public TokenResponse login(LoginRequest req, HttpServletRequest request, HttpServletResponse response) { + LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("아이디 혹은 비밀번호를 잘못 입력했습니다.")); + + if (!passwordEncoder.matches(req.getPassword(), localAccount.getPassword())) { + throw new IllegalArgumentException("아이디 혹은 비밀번호를 잘못 입력했습니다."); + } + + + User user = localAccount.getUser(); + user.setLastLoginAt(LocalDateTime.now()); + + List roles = List.of(Role.USER.toString()); + String access = jwt.createAccessToken(user.getId(), roles); + String refresh = jwt.createRefreshToken(user.getId(), req.getDeviceId()); + + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + refreshService.saveLoginRefresh(user.getId(), refresh, req.getDeviceId(), ip, ua); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); + return new TokenResponse(access, null); + } + + + public List getRoles(Long userId) { - return List.of("ROLE_USER"); + return List.of(Role.USER.toString()); + } + + //대 소문자 구별 + private static String normalizeEmail(String email) { + if (email == null) return null; + return email.trim().toLowerCase(Locale.ROOT); + } + + private static void validateParams(String email, String rawPassword, String nickname) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("이메일을 입력해주세요."); + } + if (rawPassword == null || rawPassword.isBlank()) { + throw new IllegalArgumentException("비밀번호를 입력해주세요."); + } + if (nickname == null || nickname.isBlank()) { + throw new IllegalArgumentException("닉네임 입력해주세요."); + } + } + + private void isAvailable(String email, String nickname) { + if (localAccountRepository.existsByEmail(email)) { + throw new DuplicateEmailException(email); + } + + if (userRepository.existsByNickname(nickname)) { + throw new DuplicateNicknameException(nickname); + } + + } + + public static class DuplicateEmailException extends RuntimeException { + public DuplicateEmailException(String email) { + super("이미 존재하는 이메일입니다.: " + email); + } + } + + public static class DuplicateNicknameException extends RuntimeException { + public DuplicateNicknameException(String nickname) { + super("이미 존재하는 닉네임입니다.: " + nickname); + } + } + + public ResponseCookie refreshCookie(String value) { + return ResponseCookie.from(RT_COOKIE, value) + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); } - @Getter - @AllArgsConstructor - public static class AccountInfo { - private Long id; - private String email; - private String passwordHash; - private List roles; + public ResponseCookie removeRefreshCookie() { + return ResponseCookie.from(RT_COOKIE, "") + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(0) + .build(); } } From c153feab8294026820f0f13b081429d48bfcf912 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 00:13:50 +0900 Subject: [PATCH 029/527] feat: add LoginResponse DTO add accessToken, expiredIn, role --- .../demo/{ => controller}/TagDefController.java | 0 .../scriptopia/demo/dto/user/RefreshRequest.java | 13 ------------- .../{TokenResponse.java => RefreshResponse.java} | 0 3 files changed, 13 deletions(-) rename src/main/java/com/scriptopia/demo/{ => controller}/TagDefController.java (100%) delete mode 100644 src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java rename src/main/java/com/scriptopia/demo/dto/user/{TokenResponse.java => RefreshResponse.java} (100%) diff --git a/src/main/java/com/scriptopia/demo/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java similarity index 100% rename from src/main/java/com/scriptopia/demo/TagDefController.java rename to src/main/java/com/scriptopia/demo/controller/TagDefController.java diff --git a/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java b/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java deleted file mode 100644 index 7cfc8007..00000000 --- a/src/main/java/com/scriptopia/demo/dto/user/RefreshRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.scriptopia.demo.dto.user; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class RefreshRequest { - private String refreshToken; - private String deviceId; -} diff --git a/src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java b/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java similarity index 100% rename from src/main/java/com/scriptopia/demo/dto/user/TokenResponse.java rename to src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java From bc2143587a2ce49a94afd19aeda44df81ba2fb47 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 00:22:18 +0900 Subject: [PATCH 030/527] refactor: modify login --- .../demo/controller/AuthController.java | 16 +++++++++++----- .../demo/controller/TagDefController.java | 2 +- .../scriptopia/demo/dto/user/LoginResponse.java | 15 +++++++++++++++ .../demo/dto/user/RefreshResponse.java | 4 ++-- .../demo/dto/user/RegisterRequest.java | 2 +- .../demo/service/LocalAccountService.java | 15 ++++++++------- 6 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index f6168a4e..b06a3781 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,8 +1,10 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.dto.user.LoginRequest; +import com.scriptopia.demo.dto.user.LoginResponse; import com.scriptopia.demo.dto.user.RegisterRequest; -import com.scriptopia.demo.dto.user.TokenResponse; +import com.scriptopia.demo.dto.user.RefreshResponse; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.utils.service.RefreshTokenService; @@ -25,6 +27,7 @@ public class AuthController { private final LocalAccountService localAccountService; private final JwtProvider jwt; private final RefreshTokenService refreshTokenService; + private final JwtProperties props; private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; @@ -32,7 +35,7 @@ public class AuthController { - @PostMapping("register") + @PostMapping("/register") public ResponseEntity register( @RequestBody @Valid RegisterRequest registerRequest ) { @@ -42,17 +45,18 @@ public ResponseEntity register( } @PostMapping("/login") - public ResponseEntity login( + public ResponseEntity login( @RequestBody @Valid LoginRequest req, HttpServletRequest request, HttpServletResponse response ) { + return ResponseEntity.ok(localAccountService.login(req, request, response)); } // 쿠키 기반 리프레시 @PostMapping("/token/refresh") - public ResponseEntity refresh( + public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, @RequestParam(required = false) String deviceId ) { @@ -66,7 +70,7 @@ public ResponseEntity refresh( return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) - .body(new TokenResponse(pair.accessToken(), null)); + .body(new RefreshResponse(pair.accessToken(), props.accessExpSeconds())); } @PostMapping("/logout") @@ -81,6 +85,8 @@ public ResponseEntity logout( return ResponseEntity.noContent().build(); } + + private ResponseCookie refreshCookie(String value) { return ResponseCookie.from(RT_COOKIE, value) .httpOnly(true) diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index f95fe7c7..2a1566e2 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo; +package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; diff --git a/src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java b/src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java new file mode 100644 index 00000000..6b001f38 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.user; + +import com.scriptopia.demo.domain.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LoginResponse { + private String accessToken; + private Long expiresIn; + private Role role; +} diff --git a/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java b/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java index 6fca7c5b..73635cf9 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java @@ -7,7 +7,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class TokenResponse { +public class RefreshResponse { private String accessToken; - private String refreshToken; + private Long expiresIn; } diff --git a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java index 0172a5f2..6dc33a3c 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java @@ -8,7 +8,7 @@ @AllArgsConstructor @NoArgsConstructor public class RegisterRequest { - private String userEmail; + private String email; private String password; private String nickname; } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index a967e0f5..dc779680 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -1,17 +1,16 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.user.LoginRequest; +import com.scriptopia.demo.dto.user.LoginResponse; import com.scriptopia.demo.dto.user.RegisterRequest; -import com.scriptopia.demo.dto.user.TokenResponse; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.utils.service.RefreshTokenService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -23,7 +22,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Locale; -import java.util.Optional; @Service @Transactional @@ -35,6 +33,7 @@ public class LocalAccountService { private final UserRepository userRepository; private final JwtProvider jwt; private final RefreshTokenService refreshService; + private final JwtProperties prop; private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; @@ -42,7 +41,7 @@ public class LocalAccountService { @Transactional public void register(RegisterRequest request) { - String normalizedEmail = normalizeEmail(request.getUserEmail()); + String normalizedEmail = normalizeEmail(request.getEmail()); validateParams(normalizedEmail, request.getPassword(), request.getNickname()); isAvailable(normalizedEmail, request.getNickname()); @@ -75,7 +74,7 @@ public void register(RegisterRequest request) { } @Transactional - public TokenResponse login(LoginRequest req, HttpServletRequest request, HttpServletResponse response) { + public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpServletResponse response) { LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) .orElseThrow(() -> new IllegalArgumentException("아이디 혹은 비밀번호를 잘못 입력했습니다.")); @@ -96,7 +95,9 @@ public TokenResponse login(LoginRequest req, HttpServletRequest request, HttpSer refreshService.saveLoginRefresh(user.getId(), refresh, req.getDeviceId(), ip, ua); response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); - return new TokenResponse(access, null); + + + return new LoginResponse(access, prop.accessExpSeconds(), user.getRole()); } From 8b8bc3a09cddb3d158cde3e22949479a8693ebfa Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 00:45:10 +0900 Subject: [PATCH 031/527] Revert "Merge pull request #51 from Scriptopia-RPG/feature/19-action-item-function" This reverts commit 1d5b3c8aa3658f7bf2a9c074fbd286b36a49a845, reversing changes made to b4733c01e0d96258aa8a4d5c454bf868ff889b24. --- .../demo/controller/AuctionController.java | 23 +--- .../demo/controller/ItemController.java | 14 +-- .../com/scriptopia/demo/domain/ItemDef.java | 6 - .../scriptopia/demo/domain/ItemEffect.java | 10 +- .../java/com/scriptopia/demo/domain/User.java | 7 +- .../com/scriptopia/demo/domain/UserItem.java | 2 - .../demo/dto/auction/AuctionItemResponse.java | 50 -------- .../demo/dto/auction/AuctionRequest.java | 3 +- .../demo/dto/auction/TradeFilterRequest.java | 23 ---- .../demo/dto/auction/TradeResponse.java | 19 --- .../demo/dto/devlop/ItemDefResponse.java | 26 ---- .../demo/dto/devlop/ItemEffectResponse.java | 10 -- .../demo/dto/items/ItemEffectRequest.java | 1 + .../demo/repository/AuctionRepository.java | 59 +-------- .../demo/utils/service/AuctionService.java | 119 +----------------- .../demo/utils/service/ItemDefService.java | 70 +++-------- 16 files changed, 43 insertions(+), 399 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index cdce2c8b..539aa771 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,9 +1,5 @@ package com.scriptopia.demo.controller; - -import com.scriptopia.demo.dto.auction.AuctionRequest; -import com.scriptopia.demo.dto.auction.TradeResponse; -import com.scriptopia.demo.dto.auction.TradeFilterRequest; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -16,22 +12,11 @@ public class AuctionController { private final AuctionService auctionService; - @PostMapping - public ResponseEntity createAuction(@RequestBody AuctionRequest dto, - @RequestHeader("token") String userId ){ // 헤더에서 userId 가져오기 임시임 - - return ResponseEntity.ok(auctionService.createAuction(dto, userId)); - } - - - @GetMapping - public ResponseEntity getTrades( - @RequestBody TradeFilterRequest requestDto) { - - TradeResponse response = auctionService.getTrades(requestDto); - return ResponseEntity.ok(response); + public ResponseEntity createAuction( + @RequestBody com.scriptopia.demo.dto.auction.AuctionRequest requestDto, + @RequestHeader("token") String userId) { // 헤더에서 userId 가져오기 임시임 + return ResponseEntity.ok(auctionService.createAuction(requestDto, userId)); } - } diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index edb37bb8..e8e64a60 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,14 +1,14 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.domain.ItemDef; -import com.scriptopia.demo.dto.auction.AuctionRequest; -import com.scriptopia.demo.dto.devlop.ItemDefResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; -import com.scriptopia.demo.service.AuctionService; import com.scriptopia.demo.service.ItemDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/items") @@ -18,12 +18,10 @@ public class ItemController { private final ItemDefService itemDefService; @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { - ItemDefResponse savedItem = itemDefService.createItem(dto); + public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { + ItemDef savedItem = itemDefService.createItem(dto); return ResponseEntity.ok(savedItem); } - - } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index 36777c3f..4003c9eb 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -5,8 +5,6 @@ import lombok.Setter; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; @Entity @Getter @@ -41,8 +39,4 @@ public class ItemDef { private LocalDateTime createdAt; private Long price; - - @OneToMany(mappedBy = "itemDef", cascade = CascadeType.ALL, orphanRemoval = true) - private List itemEffects = new ArrayList<>(); - } diff --git a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java index 61138179..ebae0e0e 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java @@ -10,20 +10,18 @@ public class ItemEffect { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // FK: ItemDefs @ManyToOne(fetch = FetchType.LAZY) - private ItemDef itemDef; + private ItemDef itemDefs; // FK: EffectGradeDef @ManyToOne(fetch = FetchType.LAZY) private EffectGradeDef effectGradeDef; - // 예를 들어 효과 이름 + // 예를 들어 효과 이름이나 수치 같은 필드가 있다면 여기에 추가 private String effectName; - - // 아이템 효과 설명 - private String effect_description; + private int effectValue; } diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 5ada7ae0..ade7ef94 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -1,6 +1,9 @@ package com.scriptopia.demo.domain; -import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; @@ -20,8 +23,6 @@ public class User { private LocalDateTime lastLoginAt; private String profileImgUrl; - - @Enumerated(EnumType.STRING) private Role role; } diff --git a/src/main/java/com/scriptopia/demo/domain/UserItem.java b/src/main/java/com/scriptopia/demo/domain/UserItem.java index 4e10e176..9559507e 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserItem.java +++ b/src/main/java/com/scriptopia/demo/domain/UserItem.java @@ -23,7 +23,5 @@ public class UserItem { private int remainingUses; - - @Enumerated(EnumType.STRING) private TradeStatus tradeStatus; } diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java deleted file mode 100644 index 424e708d..00000000 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.scriptopia.demo.dto.auction; - -import com.scriptopia.demo.domain.TradeStatus; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - - -@Data -public class AuctionItemResponse { - - private Long auctionId; - private Long price; - private LocalDateTime createdAt; - - private UserDto seller; - private ItemDto item; - - @Data - public static class UserDto { - private Long userId; - private String nickname; - } - - @Data - public static class ItemDto { - private Long userItemId; - private Long itemDefId; - private String name; - private String description; - private String picSrc; - private int remainingUses; - private TradeStatus tradeStatus; - private String grade; - private int baseStat; - private int strength; - private int agility; - private int intelligence; - private int luck; - private List effects; - } - - @Data - public static class ItemEffectDto { - private String effectName; - private String effectDescription; - private String grade; - } -} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java index 91327c4b..78c7437f 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java @@ -6,6 +6,7 @@ @Data public class AuctionRequest { - private String itemDefId; // 단수형으로 바꿔주세요 + private String itemDefsId; + private TradeStatus tradeStatus; // ENUM이면 String으로 받아서 변환 private Long price; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java deleted file mode 100644 index 4d210513..00000000 --- a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.scriptopia.demo.dto.auction; - -import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; -import lombok.Data; - -import java.util.List; - -@Data -public class TradeFilterRequest { - - private Long pageIndex; // 0부터 시작 - private Long pageSize; // 한 페이지당 아이템 수 - - private String itemName; // 판매 아이템 이름 - private ItemType category; // 아이템 분류 (WEAPON, ARMOR, ARTIFACT, POTION) - private Long minPrice; // 최소 가격 (nullable) - private Long maxPrice; // 최대 가격 (nullable) - private Grade grade; // 아이템 등급 (nullable) - private List effectGrades; // 아이템 효과 등급 필터 (nullable) - private MainStat mainStat; // 주 스탯 (nullable) -} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java deleted file mode 100644 index 69616b7c..00000000 --- a/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -// PagedAuctionResponse.java -package com.scriptopia.demo.dto.auction; - -import lombok.Data; -import java.util.List; - -@Data -public class TradeResponse { - private List content; - private PageInfo pageInfo; - - @Data - public static class PageInfo { - private int currentPage; - private int pageSize; - private long totalItems; - private int totalPages; - } -} diff --git a/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java b/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java deleted file mode 100644 index 650fe8cd..00000000 --- a/src/main/java/com/scriptopia/demo/dto/devlop/ItemDefResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.scriptopia.demo.dto.devlop; - - -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - -@Data -public class ItemDefResponse { - private Long id; - private String name; - private String description; - private String picSrc; - private String itemType; // enum 대신 String으로 전달 - private String mainStat; // enum 대신 String - private Integer baseStat; - private Integer strength; - private Integer agility; - private Integer intelligence; - private Integer luck; - private Long price; - private LocalDateTime createdAt; - - private List effects; -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java b/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java deleted file mode 100644 index 180f65c7..00000000 --- a/src/main/java/com/scriptopia/demo/dto/devlop/ItemEffectResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.scriptopia.demo.dto.devlop; - -import lombok.Data; - -@Data -public class ItemEffectResponse { - private String effectName; - private String effectDescription; - private String grade; // enum 대신 String -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java index aba3d1c3..ca4cf982 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java @@ -8,4 +8,5 @@ public class ItemEffectRequest { private String effectName; private String effectDescription; private Grade grade; + private Integer effectValue; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 9d58bd3b..71ae01a0 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -1,64 +1,9 @@ package com.scriptopia.demo.repository; -import com.scriptopia.demo.domain.*; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - +import com.scriptopia.demo.domain.Auction; +import com.scriptopia.demo.domain.UserItem; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; public interface AuctionRepository extends JpaRepository { boolean existsByUserItem(UserItem userItem); - - - // itemName 우선 검색 (연관 테이블 조인) - @Query(""" - SELECT DISTINCT a FROM Auction a - JOIN FETCH a.userItem ui - JOIN FETCH ui.user u - JOIN FETCH ui.itemDef idf - LEFT JOIN FETCH idf.itemEffects ie - LEFT JOIN FETCH ie.effectGradeDef e - WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) - ORDER BY a.createdAt DESC - """) - Page findByItemName(@Param("itemName") String itemName, Pageable pageable); - - - // 필터 조건 검색 (itemName 없는 경우) - @Query(""" - SELECT DISTINCT a - FROM Auction a - JOIN a.userItem ui - JOIN ui.itemDef id - LEFT JOIN id.itemEffects ie - WHERE (:category IS NULL OR id.itemType = :category) - AND (:grade IS NULL OR id.itemGradeDef.grade = :grade) - AND (:minPrice IS NULL OR a.price >= :minPrice) - AND (:maxPrice IS NULL OR a.price <= :maxPrice) - AND (:mainStat IS NULL OR id.mainStat = :mainStat) - AND ( - :effectGrades IS NULL - OR EXISTS ( - SELECT 1 FROM ItemEffect ie2 - WHERE ie2.itemDef = id - AND ie2.effectGradeDef.grade IN :effectGrades - ) - ) -""") - Page findByFilters( - @Param("category") ItemType category, - @Param("grade") Grade grade, - @Param("minPrice") Long minPrice, - @Param("maxPrice") Long maxPrice, - @Param("mainStat") MainStat mainStat, - @Param("effectGrades") List effectGrades, - Pageable pageable - ); - - - } diff --git a/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java b/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java index b3c90f46..03dce1db 100644 --- a/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java @@ -4,27 +4,16 @@ import com.scriptopia.demo.domain.TradeStatus; import com.scriptopia.demo.domain.UserItem; import com.scriptopia.demo.dto.auction.AuctionRequest; -import com.scriptopia.demo.dto.auction.AuctionItemResponse; -import com.scriptopia.demo.dto.auction.TradeResponse; -import com.scriptopia.demo.dto.auction.TradeFilterRequest; import com.scriptopia.demo.repository.AuctionRepository; import com.scriptopia.demo.repository.UserItemRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.transaction.annotation.Transactional; - import java.time.LocalDateTime; -import java.util.List; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class AuctionService { private final AuctionRepository auctionRepository; @@ -36,7 +25,7 @@ public String createAuction(AuctionRequest requestDto, String userId) { // UUID(String) → Long 변환 (임시) long userItemId; try { - userItemId = Long.parseLong(requestDto.getItemDefId()); + userItemId = Long.parseLong(requestDto.getItemDefsId()); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid UserItem UUID"); } @@ -74,108 +63,4 @@ public String createAuction(AuctionRequest requestDto, String userId) { return "등록 완료: 가격=" + requestDto.getPrice(); } - - - - - public TradeResponse getTrades(TradeFilterRequest request){ - int page = request.getPageIndex().intValue(); - int size = request.getPageSize().intValue(); - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - - Page auctionPage; - if (request.getItemName() != null && !request.getItemName().isEmpty()) { - // itemName으로 검색, 연관 테이블까지 fetch - auctionPage = auctionRepository.findByItemName(request.getItemName(), pageable); - } else { - // 이름이 없으면 다른 필터 조건으로 검색 - auctionPage = auctionRepository.findByFilters( - request.getCategory(), - request.getGrade(), - request.getMinPrice(), - request.getMaxPrice(), - request.getMainStat(), - request.getEffectGrades(), - pageable - ); - } - - - - List items = auctionPage.stream() - .map(a -> { - AuctionItemResponse dto = new AuctionItemResponse(); - dto.setAuctionId(a.getId()); - dto.setPrice(a.getPrice()); - dto.setCreatedAt(a.getCreatedAt()); - - // seller 정보 - AuctionItemResponse.UserDto userDto = new AuctionItemResponse.UserDto(); - userDto.setUserId(a.getUserItem().getUser().getId()); - userDto.setNickname(a.getUserItem().getUser().getNickname()); - dto.setSeller(userDto); - - // item 정보 - AuctionItemResponse.ItemDto itemDto = new AuctionItemResponse.ItemDto(); - itemDto.setUserItemId(a.getUserItem().getId()); - itemDto.setItemDefId(a.getUserItem().getItemDef().getId()); - itemDto.setName(a.getUserItem().getItemDef().getName()); - itemDto.setDescription(a.getUserItem().getItemDef().getDescription()); - itemDto.setPicSrc(a.getUserItem().getItemDef().getPicSrc()); - itemDto.setRemainingUses(a.getUserItem().getRemainingUses()); - itemDto.setTradeStatus(a.getUserItem().getTradeStatus()); - itemDto.setGrade(String.valueOf(a.getUserItem().getItemDef().getItemGradeDef().getGrade())); - itemDto.setBaseStat(a.getUserItem().getItemDef().getBaseStat()); - itemDto.setStrength(a.getUserItem().getItemDef().getStrength()); - itemDto.setAgility(a.getUserItem().getItemDef().getAgility()); - itemDto.setIntelligence(a.getUserItem().getItemDef().getIntelligence()); - itemDto.setLuck(a.getUserItem().getItemDef().getLuck()); - - // 효과 리스트 → 조건과 관계없이 "모든 효과"를 포함 - // (JPQL에서 한 개만 남았더라도, Hibernate 엔티티를 통해 전체 컬렉션 다시 로딩) - a.getUserItem().getItemDef().getItemEffects().size(); // lazy 초기화 강제 - - List effects = - a.getUserItem().getItemDef().getItemEffects().stream() - .map(e -> { - AuctionItemResponse.ItemEffectDto effDto = new AuctionItemResponse.ItemEffectDto(); - effDto.setEffectName(e.getEffectName()); - effDto.setEffectDescription(e.getEffect_description()); - effDto.setGrade(e.getEffectGradeDef().getGrade().name()); - return effDto; - }) - .toList(); - - itemDto.setEffects(effects); - dto.setItem(itemDto); - - return dto; - }) - .toList(); - - - TradeResponse response = new TradeResponse(); - response.setContent(items); - - TradeResponse.PageInfo pageInfo = new TradeResponse.PageInfo(); - pageInfo.setCurrentPage(auctionPage.getNumber()); // 현재 페이지 - pageInfo.setPageSize(auctionPage.getSize()); // 페이지 당 항목 수 - pageInfo.setTotalPages(auctionPage.getTotalPages()); // 전체 페이지 수 - pageInfo.setTotalItems(auctionPage.getTotalElements()); // 전체 아이템 수 - - response.setPageInfo(pageInfo); - - return response; - - - } - - - - - - - - - } diff --git a/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java index be728df2..6cdff11d 100644 --- a/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java @@ -1,39 +1,39 @@ package com.scriptopia.demo.utils.service; + import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.domain.ItemEffect; import com.scriptopia.demo.domain.ItemGradeDef; -import com.scriptopia.demo.dto.devlop.ItemDefResponse; -import com.scriptopia.demo.dto.devlop.ItemEffectResponse; -import com.scriptopia.demo.dto.items.*; +import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.dto.items.ItemEffectRequest; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; +import com.scriptopia.demo.repository.ItemEffectRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class ItemDefService { private final ItemDefRepository itemDefRepository; + private final ItemEffectRepository itemEffectRepository; private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; @Transactional - public ItemDefResponse createItem(ItemDefRequest dto) { - // ItemGradeDef 조회 + public ItemDef createItem(ItemDefRequest dto) { + // 1️⃣ ItemGradeDef 조회 ItemGradeDef gradeDef = itemGradeDefRepository.findById(dto.getItemGradeDefId()) .orElseThrow(() -> new IllegalArgumentException("ItemGradeDef not found")); - // ItemDef 생성 + // 2️⃣ ItemDef 생성 ItemDef itemDef = new ItemDef(); itemDef.setName(dto.getName()); itemDef.setDescription(dto.getDescription()); @@ -49,58 +49,24 @@ public ItemDefResponse createItem(ItemDefRequest dto) { itemDef.setItemGradeDef(gradeDef); itemDef.setCreatedAt(LocalDateTime.now()); - // ItemEffect 생성 + itemDefRepository.save(itemDef); + + // 3️⃣ ItemEffect 생성 if (dto.getEffects() != null) { for (ItemEffectRequest effectDto : dto.getEffects()) { EffectGradeDef effectGradeDef = effectGradeDefRepository.findById(effectDto.getGrade().ordinal() + 1L) .orElseThrow(() -> new IllegalArgumentException("EffectGradeDef not found")); ItemEffect effect = new ItemEffect(); - effect.setItemDef(itemDef); + effect.setItemDefs(itemDef); effect.setEffectGradeDef(effectGradeDef); effect.setEffectName(effectDto.getEffectName()); - effect.setEffect_description(effectDto.getEffectDescription()); + effect.setEffectValue(effectDto.getEffectValue()); - itemDef.getItemEffects().add(effect); + itemEffectRepository.save(effect); } } - // ItemDef 저장 (cascade로 ItemEffect도 같이 저장) - itemDefRepository.save(itemDef); - - // DTO 변환 후 반환 - return toResponse(itemDef); - } - - // ================== DTO 변환 ================== - private ItemDefResponse toResponse(ItemDef itemDef) { - ItemDefResponse response = new ItemDefResponse(); - response.setId(itemDef.getId()); - response.setName(itemDef.getName()); - response.setDescription(itemDef.getDescription()); - response.setPicSrc(itemDef.getPicSrc()); - response.setItemType(itemDef.getItemType().name()); - response.setMainStat(itemDef.getMainStat().name()); - response.setBaseStat(itemDef.getBaseStat()); - response.setStrength(itemDef.getStrength()); - response.setAgility(itemDef.getAgility()); - response.setIntelligence(itemDef.getIntelligence()); - response.setLuck(itemDef.getLuck()); - response.setPrice(itemDef.getPrice()); - response.setCreatedAt(itemDef.getCreatedAt()); - - List effects = itemDef.getItemEffects().stream() - .map(effect -> { - ItemEffectResponse eResp = new ItemEffectResponse(); - eResp.setEffectName(effect.getEffectName()); - eResp.setEffectDescription(effect.getEffect_description()); - eResp.setGrade(effect.getEffectGradeDef().getGrade().name()); - return eResp; - }) - .collect(Collectors.toList()); - - response.setEffects(effects); - - return response; + return itemDef; } -} +} \ No newline at end of file From 8c6e3488d6e20f337506b5a78d9bf2ba4fbdbb8b Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 00:59:04 +0900 Subject: [PATCH 032/527] refactor: move files --- .../com/scriptopia/demo/{utils => }/service/AuctionService.java | 0 .../com/scriptopia/demo/{utils => }/service/ItemDefService.java | 0 .../scriptopia/demo/{utils => }/service/RefreshTokenService.java | 0 .../com/scriptopia/demo/{utils => }/service/TagDefService.java | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/com/scriptopia/demo/{utils => }/service/AuctionService.java (100%) rename src/main/java/com/scriptopia/demo/{utils => }/service/ItemDefService.java (100%) rename src/main/java/com/scriptopia/demo/{utils => }/service/RefreshTokenService.java (100%) rename src/main/java/com/scriptopia/demo/{utils => }/service/TagDefService.java (100%) diff --git a/src/main/java/com/scriptopia/demo/utils/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java similarity index 100% rename from src/main/java/com/scriptopia/demo/utils/service/AuctionService.java rename to src/main/java/com/scriptopia/demo/service/AuctionService.java diff --git a/src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java similarity index 100% rename from src/main/java/com/scriptopia/demo/utils/service/ItemDefService.java rename to src/main/java/com/scriptopia/demo/service/ItemDefService.java diff --git a/src/main/java/com/scriptopia/demo/utils/service/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java similarity index 100% rename from src/main/java/com/scriptopia/demo/utils/service/RefreshTokenService.java rename to src/main/java/com/scriptopia/demo/service/RefreshTokenService.java diff --git a/src/main/java/com/scriptopia/demo/utils/service/TagDefService.java b/src/main/java/com/scriptopia/demo/service/TagDefService.java similarity index 100% rename from src/main/java/com/scriptopia/demo/utils/service/TagDefService.java rename to src/main/java/com/scriptopia/demo/service/TagDefService.java From 800dbbb4f4b3b63957bd2eee5b1baba389786192 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 00:59:27 +0900 Subject: [PATCH 033/527] refactor: move files --- .../java/com/scriptopia/demo/controller/AuthController.java | 2 +- .../java/com/scriptopia/demo/controller/TagDefController.java | 2 +- src/main/java/com/scriptopia/demo/service/AuctionService.java | 2 +- src/main/java/com/scriptopia/demo/service/ItemDefService.java | 2 +- .../java/com/scriptopia/demo/service/LocalAccountService.java | 1 - .../java/com/scriptopia/demo/service/RefreshTokenService.java | 2 +- src/main/java/com/scriptopia/demo/service/TagDefService.java | 4 +--- .../java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java | 2 +- 8 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index b06a3781..c62cfb8b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -7,7 +7,7 @@ import com.scriptopia.demo.dto.user.RefreshResponse; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.utils.JwtProvider; -import com.scriptopia.demo.utils.service.RefreshTokenService; +import com.scriptopia.demo.service.RefreshTokenService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index 2a1566e2..dc28ead8 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; -import com.scriptopia.demo.utils.service.TagDefService; +import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 03dce1db..6ffa120c 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils.service; +package com.scriptopia.demo.service; import com.scriptopia.demo.domain.Auction; import com.scriptopia.demo.domain.TradeStatus; diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 6cdff11d..e65eed7c 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils.service; +package com.scriptopia.demo.service; import com.scriptopia.demo.domain.EffectGradeDef; diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index dc779680..8c45d529 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -8,7 +8,6 @@ import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.JwtProvider; -import com.scriptopia.demo.utils.service.RefreshTokenService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java index 22fd477c..b950c3db 100644 --- a/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java +++ b/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils.service; +package com.scriptopia.demo.service; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.record.RefreshSession; diff --git a/src/main/java/com/scriptopia/demo/service/TagDefService.java b/src/main/java/com/scriptopia/demo/service/TagDefService.java index 86f20e0b..22c236cd 100644 --- a/src/main/java/com/scriptopia/demo/service/TagDefService.java +++ b/src/main/java/com/scriptopia/demo/service/TagDefService.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils.service; +package com.scriptopia.demo.service; import com.scriptopia.demo.domain.TagDef; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; @@ -9,8 +9,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @Service @RequiredArgsConstructor public class TagDefService { diff --git a/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java index 3671f202..1c0e2578 100644 --- a/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java +++ b/src/test/java/com/scriptopia/demo/jwt/RefreshTokenServiceTest.java @@ -3,7 +3,7 @@ import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.repository.RedisRefreshRepository; import com.scriptopia.demo.repository.RefreshRepository; -import com.scriptopia.demo.utils.service.RefreshTokenService; +import com.scriptopia.demo.service.RefreshTokenService; import com.scriptopia.demo.utils.JwtKeyFactory; import com.scriptopia.demo.utils.JwtProvider; import org.junit.jupiter.api.BeforeEach; From 51aff5e64fb6ced085ff21e237f229c9bf7bc96d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 18:23:25 +0900 Subject: [PATCH 034/527] feat: add email verification dependence and environment variables --- .gitignore | 2 +- build.gradle | 13 ++++++++----- .../demo/controller/AuctionController.java | 2 ++ .../demo/repository/RedisRefreshRepository.java | 2 +- src/main/resources/application.yml | 13 ++++++++++++- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 16b60f04..5410b950 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,5 @@ out/ /docker_compose_files/postgres_data /docker_compose_files/redis_data - +*.properties diff --git a/build.gradle b/build.gradle index 06374eec..24047715 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ repositories { } dependencies { - // 🔧 핵심 의존성 + // 기본 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' @@ -32,19 +32,19 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' - // 🐳 DB 관련 + // DB 관련 runtimeOnly 'org.postgresql:postgresql' - // 📦 NoSQL (MongoDB, Redis) + // NoSQL (MongoDB, Redis) implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // 🔧 Lombok + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - // ✅ 테스트 + // 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' @@ -53,6 +53,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-validation' + + //이메일 인증 + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 539aa771..28ca009e 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -19,4 +19,6 @@ public ResponseEntity createAuction( return ResponseEntity.ok(auctionService.createAuction(requestDto, userId)); } + + } diff --git a/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java b/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java index 6e2e1765..c4014d15 100644 --- a/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/RedisRefreshRepository.java @@ -46,7 +46,7 @@ public void save(RefreshSession s) { // 디바이스 인덱스 if (s.deviceId() != null) { String deviceKey = kDeviceIdx(s.userId(), s.deviceId()); - // 같은 디바이스에서 새로 로그인하면 “이전 JTI”는 무의미해지므로 덮어쓰기 + redis.opsForValue().set(deviceKey, s.jti(), Duration.ofSeconds(ttlSec)); } } catch (Exception e) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a031f183..ec58179c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,11 +16,22 @@ spring: data: mongodb: uri: mongodb://root:tiger@localhost:27017/scriptopia_mongo?authSource=admin + mail: + host: smtp.gmail.com + port: 587 + username: ${SPRING_MAIL_NAME} + password: ${SPRING_MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true auth: jwt: issuer: scriptopia access-exp-seconds: 1800 refresh-exp-seconds: 1209600 - secret: "${JWT_SECRET}" + secret: ${JWT_SECRET} From ab5a9e2727357884c023909f0944edad515aafe2 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 18:29:38 +0900 Subject: [PATCH 035/527] feat: Implement Email verification service --- .../scriptopia/demo/domain/LocalAccount.java | 1 + .../java/com/scriptopia/demo/domain/User.java | 2 ++ .../scriptopia/demo/service/MailService.java | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/service/MailService.java diff --git a/src/main/java/com/scriptopia/demo/domain/LocalAccount.java b/src/main/java/com/scriptopia/demo/domain/LocalAccount.java index 9f4eac3d..d196a744 100644 --- a/src/main/java/com/scriptopia/demo/domain/LocalAccount.java +++ b/src/main/java/com/scriptopia/demo/domain/LocalAccount.java @@ -19,6 +19,7 @@ public class LocalAccount { @OneToOne(fetch = FetchType.LAZY) private User user; + @Column(unique = true, nullable = false) private String email; private String password; private LocalDateTime updatedAt; diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index ade7ef94..9386516f 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -17,7 +17,9 @@ public class User { @Id @GeneratedValue private Long id; + private String nickname; + private Long pia; private LocalDateTime createdAt; private LocalDateTime lastLoginAt; diff --git a/src/main/java/com/scriptopia/demo/service/MailService.java b/src/main/java/com/scriptopia/demo/service/MailService.java new file mode 100644 index 00000000..c22a6075 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/MailService.java @@ -0,0 +1,22 @@ +package com.scriptopia.demo.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MailService { + private final JavaMailSender mailSender; + + public void sendVerificationCode(String toEmail, String code) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("회원가입 이메일 인증번호"); + message.setText("인증번호: " + code + "\n5분 이내에 입력해주세요."); + mailSender.send(message); + + } + +} From f28f2e1079c395cf6917635f1233880e61e26f5f Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 18:41:03 +0900 Subject: [PATCH 036/527] feat: Implement manage email verification with redis and refactor entity add UserStatus to local-account --- .../scriptopia/demo/domain/LocalAccount.java | 3 +++ .../scriptopia/demo/domain/SharedGame.java | 2 +- .../scriptopia/demo/domain/SocialAccount.java | 2 ++ .../java/com/scriptopia/demo/domain/User.java | 7 +++--- .../com/scriptopia/demo/domain/UserItem.java | 1 + .../scriptopia/demo/domain/UserSetting.java | 6 ++--- .../scriptopia/demo/domain/UserStatus.java | 5 ++++ .../scriptopia/demo/service/MailService.java | 23 +++++++++++++++++++ 8 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/domain/UserStatus.java diff --git a/src/main/java/com/scriptopia/demo/domain/LocalAccount.java b/src/main/java/com/scriptopia/demo/domain/LocalAccount.java index d196a744..af63817f 100644 --- a/src/main/java/com/scriptopia/demo/domain/LocalAccount.java +++ b/src/main/java/com/scriptopia/demo/domain/LocalAccount.java @@ -23,4 +23,7 @@ public class LocalAccount { private String email; private String password; private LocalDateTime updatedAt; + + @Enumerated(EnumType.STRING) + private UserStatus status; } diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index 661782d2..640a59e1 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -20,7 +20,7 @@ public class SharedGame { private User user; private String thumbnailUrl; - private Long recommand; + private Long recommend; private Long totalPlayed; private String title; private String worldView; diff --git a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java index bdfa1b70..623ed2ca 100644 --- a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java +++ b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java @@ -18,5 +18,7 @@ public class SocialAccount{ private User user; private String socialId; + + @Enumerated(EnumType.STRING) private Provider provider; } diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 9386516f..70ee169b 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -1,9 +1,6 @@ package com.scriptopia.demo.domain; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -25,6 +22,8 @@ public class User { private LocalDateTime lastLoginAt; private String profileImgUrl; + + @Enumerated(EnumType.STRING) private Role role; } diff --git a/src/main/java/com/scriptopia/demo/domain/UserItem.java b/src/main/java/com/scriptopia/demo/domain/UserItem.java index 9559507e..a7a63123 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserItem.java +++ b/src/main/java/com/scriptopia/demo/domain/UserItem.java @@ -23,5 +23,6 @@ public class UserItem { private int remainingUses; + @Enumerated(EnumType.STRING) private TradeStatus tradeStatus; } diff --git a/src/main/java/com/scriptopia/demo/domain/UserSetting.java b/src/main/java/com/scriptopia/demo/domain/UserSetting.java index d171841a..8bc758db 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserSetting.java +++ b/src/main/java/com/scriptopia/demo/domain/UserSetting.java @@ -1,9 +1,6 @@ package com.scriptopia.demo.domain; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.OneToOne; +import jakarta.persistence.*; import lombok.Data; import java.awt.*; @@ -21,6 +18,7 @@ public class UserSetting { private Theme theme; + @Enumerated(EnumType.STRING) private FontType fontType; private int fontSize; diff --git a/src/main/java/com/scriptopia/demo/domain/UserStatus.java b/src/main/java/com/scriptopia/demo/domain/UserStatus.java new file mode 100644 index 00000000..7b37b47e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/UserStatus.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum UserStatus { + UNVERIFIED, VERIFIED +} diff --git a/src/main/java/com/scriptopia/demo/service/MailService.java b/src/main/java/com/scriptopia/demo/service/MailService.java index c22a6075..54945f82 100644 --- a/src/main/java/com/scriptopia/demo/service/MailService.java +++ b/src/main/java/com/scriptopia/demo/service/MailService.java @@ -1,14 +1,18 @@ package com.scriptopia.demo.service; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import java.util.concurrent.TimeUnit; + @Service @RequiredArgsConstructor public class MailService { private final JavaMailSender mailSender; + private final StringRedisTemplate redisTemplate; public void sendVerificationCode(String toEmail, String code) { SimpleMailMessage message = new SimpleMailMessage(); @@ -19,4 +23,23 @@ public void sendVerificationCode(String toEmail, String code) { } + public void saveCode(String email, String code) { + redisTemplate.opsForValue().set( + "email:verify:" + email, + code, + 5, + TimeUnit.MINUTES + ); + } + + public String getCode(String email) { + return redisTemplate.opsForValue().get("email:verify" + email); + } + + public void deleteCode(String email) { + redisTemplate.delete("email:verify:" + email); + } + + + } From 96b63ca928c3226e88c65c4acdeb72a3c6e0cb90 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 24 Aug 2025 18:56:51 +0900 Subject: [PATCH 037/527] feature/50-history, service --- .../demo/controller/HistoryController.java | 18 +++ .../com/scriptopia/demo/domain/History.java | 41 +++++++ .../demo/dto/history/HistoryRequest.java | 19 ++++ .../demo/dto/history/HistoryResponse.java | 25 +++++ .../demo/service/HistoryService.java | 105 ++++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/HistoryController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java create mode 100644 src/main/java/com/scriptopia/demo/service/HistoryService.java diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java new file mode 100644 index 00000000..4aa5039f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.service.HistoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/games") +@RequiredArgsConstructor +public class HistoryController { + private final HistoryService historyService; + +// @PostMapping("/{id}/history") +// public ResponseEntity addHistory(@PathVariable Integer id, ) { +// } +} diff --git a/src/main/java/com/scriptopia/demo/domain/History.java b/src/main/java/com/scriptopia/demo/domain/History.java index bc0d3199..5042eabc 100644 --- a/src/main/java/com/scriptopia/demo/domain/History.java +++ b/src/main/java/com/scriptopia/demo/domain/History.java @@ -1,8 +1,10 @@ package com.scriptopia.demo.domain; +import com.scriptopia.demo.dto.history.HistoryRequest; import jakarta.persistence.*; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; @@ -10,6 +12,7 @@ @Entity @Getter @Setter +@NoArgsConstructor public class History { @Id @GeneratedValue @@ -19,18 +22,56 @@ public class History { private User user; private String thumbnailUrl; + + @Column(columnDefinition = "TEXT") private String title; + + @Column(columnDefinition = "TEXT") private String worldView; + + @Column(columnDefinition = "TEXT") private String backgroundStory; + + @Column(columnDefinition = "TEXT") private String worldPrompt; + + @Column(columnDefinition = "TEXT") private String epilogue1Title; + + @Column(columnDefinition = "TEXT") private String epilogue1Content; + + @Column(columnDefinition = "TEXT") private String epilogue2Title; + + @Column(columnDefinition = "TEXT") private String epilogue2Content; + + @Column(columnDefinition = "TEXT") private String epilogue3Title; + + @Column(columnDefinition = "TEXT") private String epilogue3Content; private Long score; private LocalDateTime createdAt; private Boolean isShared; + + public History(User id, HistoryRequest req) { + this.user = id; + this.thumbnailUrl = req.getThumbnailUrl(); + this.title = req.getTitle(); + this.worldView = req.getWorldView(); + this.backgroundStory = req.getBackgroundStory(); + this.worldPrompt = req.getWorldPrompt(); + this.epilogue1Title = req.getEpilogue1Title(); + this.epilogue1Content = req.getEpilogue1Content(); + this.epilogue2Title = req.getEpilogue2Title(); + this.epilogue2Content = req.getEpilogue2Content(); + this.epilogue3Title = req.getEpilogue3Title(); + this.epilogue3Content = req.getEpilogue3Content(); + this.score = req.getScore(); + this.createdAt = LocalDateTime.now(); + this.isShared = false; + } } diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java new file mode 100644 index 00000000..01cdb1e8 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java @@ -0,0 +1,19 @@ +package com.scriptopia.demo.dto.history; + +import lombok.Data; + +@Data +public class HistoryRequest { + private String thumbnailUrl; + private String title; + private String worldView; + private String backgroundStory; + private String worldPrompt; + private String epilogue1Title; + private String epilogue1Content; + private String epilogue2Title; + private String epilogue2Content; + private String epilogue3Title; + private String epilogue3Content; + private Long score; +} diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java new file mode 100644 index 00000000..050f69b2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java @@ -0,0 +1,25 @@ +package com.scriptopia.demo.dto.history; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class HistoryResponse { + private Long id; + private Long userId; + private String thumbnailUrl; + private String title; + private String worldView; + private String backgroundStory; + private String worldPrompt; + private String epilogue1Title; + private String epilogue1Content; + private String epilogue2Title; + private String epilogue2Content; + private String epilogue3Title; + private String epilogue3Content; + private Long score; + private LocalDateTime createdAt; + private Boolean isShared; +} diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java new file mode 100644 index 00000000..374e1230 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -0,0 +1,105 @@ +package com.scriptopia.demo.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.domain.History; +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.history.HistoryRequest; +import com.scriptopia.demo.repository.HistoryRepository; +import com.scriptopia.demo.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class HistoryService { + private final UserRepository userRepository; + private final HistoryRepository historyRepository; + private final MongoTemplate mongoTemplate; + private final ObjectMapper objectMapper; + + private static final String COLL = "game_session"; + + @Transactional + public ResponseEntity createhistory(Long id, HistoryRequest req) { + // TODO 유저 인증 구현해야함 + User user = userRepository.findById(id).get(); + History history = new History(user, req); + + return ResponseEntity.ok(historyRepository.save(history)); + } + + @Transactional + public ResponseEntity seedDummySession(Long userId) { + Document hi = new Document(Map.of( + "title", "임시 여정 제목", + "world_prompt", "임시 세계관 프롬프트", + "epilogue_1_title", "엔딩A", + "epilogue_1_content", "엔딩A 내용", + "epilogue_2_title", "엔딩B", + "epilogue_2_content", "엔딩B 내용", + "epilogue_3_title", "엔딩C", + "epilogue_3_content", "엔딩C 내용", + "score", 1234 + )); + + Document doc = new Document(); + doc.put("user_id", userId); + doc.put("scene_type", "done"); + doc.put("started_at", Instant.now()); + doc.put("updated_at", Instant.now()); + doc.put("background", "https://cdn.example.com/bg/temp.png"); // 썸네일로 매핑할 예정 + doc.put("progress", 100); + doc.put("stage", List.of(1,2,3)); + doc.put("history_info", hi); + + Document saved = mongoTemplate.insert(doc, COLL); + return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); + } + + private HistoryRequest mapMongoToHistoryRequest(Document doc) { + JsonNode root = asJson(doc); + JsonNode hi = root.path("history_info"); + + // 필수값: title, world_prompt, score + String title = hi.path("title").asText(""); + String worldPrompt = hi.path("world_prompt").asText(""); + Integer score = hi.path("score").isNumber() ? hi.path("score").asInt() : null; + if (title.isBlank() || worldPrompt.isBlank() || score == null) { + throw new IllegalArgumentException("history_info의 필수값(title, world_prompt, score)이 누락되었습니다."); + } + + HistoryRequest req = new HistoryRequest(); + // thumbnailUrl: Mongo의 background를 임시 썸네일로 사용 + req.setThumbnailUrl(root.path("background").isTextual() ? root.get("background").asText() : null); + + req.setTitle(title); + // 정책에 맞게 매핑: worldView는 비워두거나 world_prompt로 대체 가능 + req.setWorldView(null); // 또는 req.setWorldView(worldPrompt); + req.setBackgroundStory(null); // 필요 시 done_info.story 등에서 요약해 채우기 + req.setWorldPrompt(worldPrompt); + + req.setEpilogue1Title(hi.path("epilogue_1_title").asText(null)); + req.setEpilogue1Content(hi.path("epilogue_1_content").asText(null)); + req.setEpilogue2Title(hi.path("epilogue_2_title").asText(null)); + req.setEpilogue2Content(hi.path("epilogue_2_content").asText(null)); + req.setEpilogue3Title(hi.path("epilogue_3_title").asText(null)); + req.setEpilogue3Content(hi.path("epilogue_3_content").asText(null)); + + req.setScore(score.longValue()); + return req; + } + + private JsonNode asJson(Document doc) { + try { return objectMapper.readTree(doc.toJson()); } + catch (Exception e) { throw new RuntimeException("Mongo Document → JsonNode 변환 실패", e); } + } +} From b95f5271e54f64cf399bfd49996e4a47a6c5b9b0 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 19:02:09 +0900 Subject: [PATCH 038/527] feat: Implement send mail and verify code --- build.gradle | 1 + .../java/com/scriptopia/demo/domain/User.java | 1 + .../demo/service/LocalAccountService.java | 36 +++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 24047715..3b61d33a 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-validation' // DB 관련 runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 70ee169b..8958ba35 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -23,6 +23,7 @@ public class User { private String profileImgUrl; + @Enumerated(EnumType.STRING) private Role role; diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 8c45d529..40326428 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,12 +22,14 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Locale; +import java.util.concurrent.TimeUnit; @Service @Transactional @RequiredArgsConstructor public class LocalAccountService { + private final StringRedisTemplate redisTemplate; private final LocalAccountRepository localAccountRepository; private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; @@ -37,11 +40,32 @@ public class LocalAccountService { private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; + private final MailService mailService; + + @Transactional + public void sendVerificationCode(String email) { + String code = String.format("%06d", (int)(Math.random() * 999999)); + mailService.saveCode(email, code); + mailService.sendVerificationCode(email, code); + } + + public boolean verifyCode(String email, String inputCode) { + String savedCode = redisTemplate.opsForValue().get("email:verify:" + email); + if (savedCode != null && savedCode.equals(inputCode)) { + // 인증 완료 후 30분 유지 + redisTemplate.opsForValue().set("email:verified:" + email, "true", 30, TimeUnit.MINUTES); + redisTemplate.delete("email:verify:" + email); // 코드 제거 + return true; + } + return false; + } @Transactional public void register(RegisterRequest request) { String normalizedEmail = normalizeEmail(request.getEmail()); - validateParams(normalizedEmail, request.getPassword(), request.getNickname()); + String verified = redisTemplate.opsForValue().get("email:verified:" + normalizedEmail); + validateParams(verified, normalizedEmail, request.getPassword(), request.getNickname()); + isAvailable(normalizedEmail, request.getNickname()); //user 객체 생성 @@ -60,6 +84,7 @@ public void register(RegisterRequest request) { localAccount.setEmail(normalizedEmail); localAccount.setPassword(passwordEncoder.encode(request.getPassword())); localAccount.setUpdatedAt(LocalDateTime.now()); + localAccount.setStatus(UserStatus.UNVERIFIED); localAccountRepository.save(localAccount); //환경 설정 초기 값 @@ -111,10 +136,17 @@ private static String normalizeEmail(String email) { return email.trim().toLowerCase(Locale.ROOT); } - private static void validateParams(String email, String rawPassword, String nickname) { + private static void validateParams(String verified, String email, String rawPassword, String nickname) { + + if (verified == null || !verified.equals("true")) { + throw new RuntimeException("이메일 인증을 먼저 완료해야 합니다."); + } + if (email == null || email.isBlank()) { throw new IllegalArgumentException("이메일을 입력해주세요."); } + + if (rawPassword == null || rawPassword.isBlank()) { throw new IllegalArgumentException("비밀번호를 입력해주세요."); } From ffb91bdc1bb9d2f7bf754c9e1859a1b05ba52b0c Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 24 Aug 2025 19:24:41 +0900 Subject: [PATCH 039/527] feat: implement email verification and signup flow --- .../demo/controller/AuthController.java | 17 +++++++++++++++++ .../demo/dto/user/RegisterRequest.java | 16 ++++++++++++++++ .../scriptopia/demo/service/MailService.java | 4 ++++ 3 files changed, 37 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index c62cfb8b..904a4031 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -34,6 +34,23 @@ public class AuthController { private static final String COOKIE_SAMESITE = "None"; + @PostMapping("/send-code") + public ResponseEntity sendCode(@RequestParam String email) { + localAccountService.sendVerificationCode(email); + return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); + } + + @PostMapping("/verify-code") + public ResponseEntity verifyCode(@RequestParam String email, + @RequestParam String code) { + boolean success = localAccountService.verifyCode(email, code); + if (success) { + return ResponseEntity.ok("이메일 인증이 완료되었습니다."); + } else { + return ResponseEntity.badRequest().body("인증번호가 올바르지 않거나 만료되었습니다."); + } + } + @PostMapping("/register") public ResponseEntity register( diff --git a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java index 6dc33a3c..412b95ea 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java @@ -1,5 +1,9 @@ package com.scriptopia.demo.dto.user; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,7 +12,19 @@ @AllArgsConstructor @NoArgsConstructor public class RegisterRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", + message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + ) private String password; + + @NotBlank(message = "닉네임은 필수 입력 값입니다.") private String nickname; } diff --git a/src/main/java/com/scriptopia/demo/service/MailService.java b/src/main/java/com/scriptopia/demo/service/MailService.java index 54945f82..b8ab7b0c 100644 --- a/src/main/java/com/scriptopia/demo/service/MailService.java +++ b/src/main/java/com/scriptopia/demo/service/MailService.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.service; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @@ -13,10 +14,13 @@ public class MailService { private final JavaMailSender mailSender; private final StringRedisTemplate redisTemplate; + @Value("${spring.mail.username}") + private String fromEmail; public void sendVerificationCode(String toEmail, String code) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(toEmail); + message.setFrom(fromEmail); message.setSubject("회원가입 이메일 인증번호"); message.setText("인증번호: " + code + "\n5분 이내에 입력해주세요."); mailSender.send(message); From 0ac0ef93e46dc58ce713d5f93a9b9276ce2c1f9b Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 25 Aug 2025 01:11:42 +0900 Subject: [PATCH 040/527] featur/ history-save, service, mongodb --- docker_compose_files/.env.swp | Bin 0 -> 12288 bytes .../demo/controller/HistoryController.java | 35 ++++++++++- .../demo/service/HistoryService.java | 58 ++++++++++++++---- 3 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 docker_compose_files/.env.swp diff --git a/docker_compose_files/.env.swp b/docker_compose_files/.env.swp new file mode 100644 index 0000000000000000000000000000000000000000..0a82c195379762dc07d5d52eb3d3ee8284be0618 GIT binary patch literal 12288 zcmeI&zfQtH90%|p{z+VnMqdDBhJsEG#6U_2Luu23j!i9Rxq!WyT*XybLdQcDKLoiS#6nrb@;6 zqyK975X6xdDvrw2NnD=DvK53)rA7^zR>mp}#y@XAE*cu9(4$Nxh7MDi@P!PFfeJ?= z^+R=+C@uV3ITqTeh?w_*f&c{8A+S&9XSHhHsdRc$JU;5LqbYho00Izz00bZa0SG_< z0vjTbB?Wroy>89-yEh*TE8~2M0Rj+!00bZa0SG_<0uX=z1Rwx`4HO6lM70AV$;to! zFTVe0Jpbf;aNap@oL9~>Cto|{pdbJN2tWV=5P$##AOHafKmY;|SP`)7Zrk=d-A>PJ z`mSyFe5c`g*S2fcGZ~4L{x0ZyEw|gSTJ=<^j7mj|PuMFW$M$+{x8?a}v%Wk)zr4ND R_jh^TZqdq7I*U@F=^IkGXJY^W literal 0 HcmV?d00001 diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index 4aa5039f..6f9f218c 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.history.HistoryRequest; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; @@ -12,7 +13,35 @@ public class HistoryController { private final HistoryService historyService; -// @PostMapping("/{id}/history") -// public ResponseEntity addHistory(@PathVariable Integer id, ) { -// } + /** + * 하나의 엔드포인트로 3가지 모드 지원: + * 1) 바디 저장: POST /games/{id}/history (Body = HistoryRequest) + * 2) 특정 세션 저장: POST /games/{id}/history?sessionId=... (Query) + * 3) 최신 세션 저장: POST /games/{id}/history?latest=true (Query) + */ + @PostMapping("/{id}/history") + public ResponseEntity addHistory(@PathVariable Long id, + @RequestParam(required = false) String sessionId, + @RequestParam(required = false, defaultValue = "false") boolean latest, + @RequestBody(required = false) HistoryRequest body) { + if (sessionId != null && !sessionId.isBlank()) { + // Mongo 특정 세션(ObjectId)에서 읽어 HistoryRequest로 변환 후 저장 + return historyService.createFromMongoSession(id, sessionId); + } + if (latest) { + // Mongo 최신(updated_at DESC) 세션에서 읽어 HistoryRequest로 변환 후 저장 + return historyService.createFromMongoLatest(id); + } + if (body != null) { + // 프론트가 보낸 HistoryRequest 바디로 저장 + return historyService.createhistory(id, body); + } + return ResponseEntity.badRequest().body("body 또는 sessionId/latest 파라미터를 제공하세요."); + } + + /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ + @PostMapping("/{id}/history/seed") + public ResponseEntity seed(@PathVariable Long id) { + return historyService.seedDummySession(id); + } } diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 374e1230..0f60fa46 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -9,7 +9,11 @@ import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,16 +43,18 @@ public ResponseEntity createhistory(Long id, HistoryRequest req) { @Transactional public ResponseEntity seedDummySession(Long userId) { - Document hi = new Document(Map.of( - "title", "임시 여정 제목", - "world_prompt", "임시 세계관 프롬프트", - "epilogue_1_title", "엔딩A", - "epilogue_1_content", "엔딩A 내용", - "epilogue_2_title", "엔딩B", - "epilogue_2_content", "엔딩B 내용", - "epilogue_3_title", "엔딩C", - "epilogue_3_content", "엔딩C 내용", - "score", 1234 + Document hi = new Document(Map.ofEntries( + Map.entry("title", "임시 여정 제목"), + Map.entry("world_prompt", "임시 세계관 프롬프트"), + Map.entry("background_story", "AI가 생성한 배경 이야기"), + Map.entry("world_view", "AI가 생성한 세계관"), + Map.entry("epilogue_1_title", "엔딩A"), + Map.entry("epilogue_1_content", "엔딩A 내용"), + Map.entry("epilogue_2_title", "엔딩B"), + Map.entry("epilogue_2_content", "엔딩B 내용"), + Map.entry("epilogue_3_title", "엔딩C"), + Map.entry("epilogue_3_content", "엔딩C 내용"), + Map.entry("score", 1234) )); Document doc = new Document(); @@ -65,6 +71,34 @@ public ResponseEntity seedDummySession(Long userId) { return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); } + @Transactional + public ResponseEntity createFromMongoLatest(Long userId) { + Query q = Query.query(Criteria.where("user_id").is(userId)) + .with(Sort.by(Sort.Direction.DESC, "updated_at")) + .limit(1); + + q.fields().include("user_id").include("updated_at").include("background").include("history_info"); + Document doc = mongoTemplate.findOne(q, Document.class, COLL); + if(doc == null) return ResponseEntity.badRequest().body("해당 유저의 Mongo 세션이 없습니다."); + + HistoryRequest req = mapMongoToHistoryRequest(doc); + return createhistory(userId, req); + } + + @Transactional + public ResponseEntity createFromMongoSession(Long userId, String sessionIdHex) { + Document doc = mongoTemplate.findById(new ObjectId(sessionIdHex), Document.class, COLL); + if(doc == null) return ResponseEntity.badRequest().body("세션 없음"); + + JsonNode root = asJson(doc); + long owner = root.path("user_id").asLong(-1); + if(owner != userId) return ResponseEntity.status(403).body("본인 세션만 저장 가능"); + + HistoryRequest req = mapMongoToHistoryRequest(doc); + return createhistory(userId, req); + } + + private HistoryRequest mapMongoToHistoryRequest(Document doc) { JsonNode root = asJson(doc); JsonNode hi = root.path("history_info"); @@ -83,8 +117,8 @@ private HistoryRequest mapMongoToHistoryRequest(Document doc) { req.setTitle(title); // 정책에 맞게 매핑: worldView는 비워두거나 world_prompt로 대체 가능 - req.setWorldView(null); // 또는 req.setWorldView(worldPrompt); - req.setBackgroundStory(null); // 필요 시 done_info.story 등에서 요약해 채우기 + req.setBackgroundStory(hi.path("background_story").asText(null)); + req.setWorldView(hi.path("world_view").asText(null)); // 또는 req.setWorldView(worldPrompt); req.setWorldPrompt(worldPrompt); req.setEpilogue1Title(hi.path("epilogue_1_title").asText(null)); From a491896b314f5a4eca71ca7ecb085e4e26c6934d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 25 Aug 2025 22:08:43 +0900 Subject: [PATCH 041/527] feature/50 savehistory Controller implement --- docker_compose_files/.env.swp | Bin 12288 -> 0 bytes .../demo/controller/HistoryController.java | 27 ++-------- .../demo/service/HistoryService.java | 46 ++++++------------ 3 files changed, 20 insertions(+), 53 deletions(-) delete mode 100644 docker_compose_files/.env.swp diff --git a/docker_compose_files/.env.swp b/docker_compose_files/.env.swp deleted file mode 100644 index 0a82c195379762dc07d5d52eb3d3ee8284be0618..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI&zfQtH90%|p{z+VnMqdDBhJsEG#6U_2Luu23j!i9Rxq!WyT*XybLdQcDKLoiS#6nrb@;6 zqyK975X6xdDvrw2NnD=DvK53)rA7^zR>mp}#y@XAE*cu9(4$Nxh7MDi@P!PFfeJ?= z^+R=+C@uV3ITqTeh?w_*f&c{8A+S&9XSHhHsdRc$JU;5LqbYho00Izz00bZa0SG_< z0vjTbB?Wroy>89-yEh*TE8~2M0Rj+!00bZa0SG_<0uX=z1Rwx`4HO6lM70AV$;to! zFTVe0Jpbf;aNap@oL9~>Cto|{pdbJN2tWV=5P$##AOHafKmY;|SP`)7Zrk=d-A>PJ z`mSyFe5c`g*S2fcGZ~4L{x0ZyEw|gSTJ=<^j7mj|PuMFW$M$+{x8?a}v%Wk)zr4ND R_jh^TZqdq7I*U@F=^IkGXJY^W diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index 6f9f218c..82544e49 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -14,29 +14,12 @@ public class HistoryController { private final HistoryService historyService; /** - * 하나의 엔드포인트로 3가지 모드 지원: - * 1) 바디 저장: POST /games/{id}/history (Body = HistoryRequest) - * 2) 특정 세션 저장: POST /games/{id}/history?sessionId=... (Query) - * 3) 최신 세션 저장: POST /games/{id}/history?latest=true (Query) + * 현재는 userId, sessionId를 통해 저장하는데 + * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ - @PostMapping("/{id}/history") - public ResponseEntity addHistory(@PathVariable Long id, - @RequestParam(required = false) String sessionId, - @RequestParam(required = false, defaultValue = "false") boolean latest, - @RequestBody(required = false) HistoryRequest body) { - if (sessionId != null && !sessionId.isBlank()) { - // Mongo 특정 세션(ObjectId)에서 읽어 HistoryRequest로 변환 후 저장 - return historyService.createFromMongoSession(id, sessionId); - } - if (latest) { - // Mongo 최신(updated_at DESC) 세션에서 읽어 HistoryRequest로 변환 후 저장 - return historyService.createFromMongoLatest(id); - } - if (body != null) { - // 프론트가 보낸 HistoryRequest 바디로 저장 - return historyService.createhistory(id, body); - } - return ResponseEntity.badRequest().body("body 또는 sessionId/latest 파라미터를 제공하세요."); + @PostMapping("/{id}/history/{sid}") + public ResponseEntity addHistory(@PathVariable Long id, @PathVariable String sid) { + return historyService.createHistory(id, sid); } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 0f60fa46..81038ac6 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -33,9 +33,21 @@ public class HistoryService { private static final String COLL = "game_session"; @Transactional - public ResponseEntity createhistory(Long id, HistoryRequest req) { - // TODO 유저 인증 구현해야함 - User user = userRepository.findById(id).get(); + public ResponseEntity createHistory(Long userId, String sid) { + ObjectId oid = new ObjectId(sid); + + Query q = Query.query(Criteria.where("_id").is(oid)); + Document doc = mongoTemplate.findOne(q, Document.class, COLL); + if(doc == null) return ResponseEntity.badRequest().body("세션 ID 없음"); + + Object historyIdInSession = doc.get("history_id"); + + if(historyIdInSession != null) { + return ResponseEntity.ok(Map.of("historyId", ((Number)historyIdInSession).longValue())); + } + + HistoryRequest req = mapMongoToHistoryRequest(doc); + User user = userRepository.findById(userId).orElseThrow(); History history = new History(user, req); return ResponseEntity.ok(historyRepository.save(history)); @@ -71,34 +83,6 @@ public ResponseEntity seedDummySession(Long userId) { return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); } - @Transactional - public ResponseEntity createFromMongoLatest(Long userId) { - Query q = Query.query(Criteria.where("user_id").is(userId)) - .with(Sort.by(Sort.Direction.DESC, "updated_at")) - .limit(1); - - q.fields().include("user_id").include("updated_at").include("background").include("history_info"); - Document doc = mongoTemplate.findOne(q, Document.class, COLL); - if(doc == null) return ResponseEntity.badRequest().body("해당 유저의 Mongo 세션이 없습니다."); - - HistoryRequest req = mapMongoToHistoryRequest(doc); - return createhistory(userId, req); - } - - @Transactional - public ResponseEntity createFromMongoSession(Long userId, String sessionIdHex) { - Document doc = mongoTemplate.findById(new ObjectId(sessionIdHex), Document.class, COLL); - if(doc == null) return ResponseEntity.badRequest().body("세션 없음"); - - JsonNode root = asJson(doc); - long owner = root.path("user_id").asLong(-1); - if(owner != userId) return ResponseEntity.status(403).body("본인 세션만 저장 가능"); - - HistoryRequest req = mapMongoToHistoryRequest(doc); - return createhistory(userId, req); - } - - private HistoryRequest mapMongoToHistoryRequest(Document doc) { JsonNode root = asJson(doc); JsonNode hi = root.path("history_info"); From c44a55d6470fcabdfafb95c948b112c6202c205a Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 20:13:54 +0900 Subject: [PATCH 042/527] feat: add change password request dto --- .../scriptopia/demo/controller/AuthController.java | 8 ++++---- .../dto/{user => localaccount}/LoginRequest.java | 2 +- .../dto/{user => localaccount}/LoginResponse.java | 2 +- .../{user => localaccount}/RefreshResponse.java | 2 +- .../{user => localaccount}/RegisterRequest.java | 2 +- .../dto/localaccount/changePasswordRequest.java | 14 ++++++++++++++ .../demo/service/LocalAccountService.java | 12 +++++++++--- 7 files changed, 31 insertions(+), 11 deletions(-) rename src/main/java/com/scriptopia/demo/dto/{user => localaccount}/LoginRequest.java (84%) rename src/main/java/com/scriptopia/demo/dto/{user => localaccount}/LoginResponse.java (86%) rename src/main/java/com/scriptopia/demo/dto/{user => localaccount}/RefreshResponse.java (83%) rename src/main/java/com/scriptopia/demo/dto/{user => localaccount}/RegisterRequest.java (95%) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 904a4031..23b24ed7 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,10 +1,10 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.config.JwtProperties; -import com.scriptopia.demo.dto.user.LoginRequest; -import com.scriptopia.demo.dto.user.LoginResponse; -import com.scriptopia.demo.dto.user.RegisterRequest; -import com.scriptopia.demo.dto.user.RefreshResponse; +import com.scriptopia.demo.dto.localaccount.LoginRequest; +import com.scriptopia.demo.dto.localaccount.LoginResponse; +import com.scriptopia.demo.dto.localaccount.RegisterRequest; +import com.scriptopia.demo.dto.localaccount.RefreshResponse; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.service.RefreshTokenService; diff --git a/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java similarity index 84% rename from src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java rename to src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java index 32c6fd80..37864e51 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/LoginRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.user; +package com.scriptopia.demo.dto.localaccount; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java similarity index 86% rename from src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java rename to src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java index 6b001f38..6ada8212 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/LoginResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.user; +package com.scriptopia.demo.dto.localaccount; import com.scriptopia.demo.domain.Role; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java b/src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java similarity index 83% rename from src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java rename to src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java index 73635cf9..b8f6816d 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/RefreshResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.user; +package com.scriptopia.demo.dto.localaccount; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java similarity index 95% rename from src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java rename to src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java index 412b95ea..b4e3305d 100644 --- a/src/main/java/com/scriptopia/demo/dto/user/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.user; +package com.scriptopia.demo.dto.localaccount; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java new file mode 100644 index 00000000..f6739689 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.localaccount; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class changePasswordRequest { + private String email; + private String oldPassword; + private String newPassword; +} diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 40326428..e3ebb43d 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -2,9 +2,10 @@ import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.user.LoginRequest; -import com.scriptopia.demo.dto.user.LoginResponse; -import com.scriptopia.demo.dto.user.RegisterRequest; +import com.scriptopia.demo.dto.localaccount.LoginRequest; +import com.scriptopia.demo.dto.localaccount.LoginResponse; +import com.scriptopia.demo.dto.localaccount.RegisterRequest; +import com.scriptopia.demo.dto.localaccount.changePasswordRequest; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.JwtProvider; @@ -124,6 +125,11 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer return new LoginResponse(access, prop.accessExpSeconds(), user.getRole()); } + @Transactional + public void changePassword(changePasswordRequest request) { + + } + public List getRoles(Long userId) { From 09b25478b06c8f25bdf3bc3621ac6bd7cf8b2a85 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 21:42:10 +0900 Subject: [PATCH 043/527] feat: Implement change password --- .../scriptopia/demo/config/JwtAuthFilter.java | 27 ++++++++++------ .../demo/controller/AuthController.java | 16 +++++++--- .../localaccount/ChangePasswordRequest.java | 31 +++++++++++++++++++ .../localaccount/changePasswordRequest.java | 14 --------- .../repository/LocalAccountRepository.java | 3 +- .../demo/service/LocalAccountService.java | 12 +++++-- .../scriptopia/demo/utils/JwtProvider.java | 8 +++-- 7 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index b94a4d22..e819f1ed 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -26,28 +26,37 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, throws ServletException, IOException { String uri = req.getRequestURI(); - if (uri.startsWith("/auth/")) { + // 로그인/회원가입 제외, 나머지 요청은 JWT 체크 + if (uri.startsWith("/auth/login") || uri.startsWith("/auth/register")) { chain.doFilter(req, res); return; } - String auth = req.getHeader("Authorization"); - if (auth != null && auth.startsWith("Bearer ")) { - String token = auth.substring(7); + String authHeader = req.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); try { - jwt.parse(token); + jwt.parse(token); // 유효성 체크 - Long userId = jwt.getUserId(token); + String userId = jwt.getUserId(token).toString(); var roles = jwt.getRoles(token).stream() - .map(SimpleGrantedAuthority::new).collect(Collectors.toList()); + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); var authentication = new UsernamePasswordAuthenticationToken(userId, null, roles); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception ignored) { + } catch (Exception e) { + logger.error("JWT parse error", e); + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token"); + return; } + } else { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization header"); + return; } + chain.doFilter(req, res); } -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 23b24ed7..01153aa3 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,10 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.config.JwtProperties; -import com.scriptopia.demo.dto.localaccount.LoginRequest; -import com.scriptopia.demo.dto.localaccount.LoginResponse; -import com.scriptopia.demo.dto.localaccount.RegisterRequest; -import com.scriptopia.demo.dto.localaccount.RefreshResponse; +import com.scriptopia.demo.dto.localaccount.*; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.service.RefreshTokenService; @@ -15,6 +12,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.time.Duration; @@ -51,6 +49,16 @@ public ResponseEntity verifyCode(@RequestParam String email, } } + @PostMapping("/password/change") + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, + Authentication authentication) { + + Long userId = Long.valueOf(authentication.getName()); + + localAccountService.changePassword(userId,request); + + return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); + } @PostMapping("/register") public ResponseEntity register( diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java new file mode 100644 index 00000000..834ef1c1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java @@ -0,0 +1,31 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChangePasswordRequest { + + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", + message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + ) + private String oldPassword; + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", + message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + ) + private String newPassword; +} diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java deleted file mode 100644 index f6739689..00000000 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/changePasswordRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.scriptopia.demo.dto.localaccount; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class changePasswordRequest { - private String email; - private String oldPassword; - private String newPassword; -} diff --git a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java index 5657aabf..229a1207 100644 --- a/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/LocalAccountRepository.java @@ -2,6 +2,7 @@ import aj.org.objectweb.asm.commons.Remapper; import com.scriptopia.demo.domain.LocalAccount; +import com.scriptopia.demo.domain.User; import org.springframework.cglib.core.Local; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -12,7 +13,7 @@ public interface LocalAccountRepository extends JpaRepository { Optional findByEmail(String email); - + Optional findByUserId(Long user_id); boolean existsByEmail(String email); } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index e3ebb43d..1089a782 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -5,7 +5,7 @@ import com.scriptopia.demo.dto.localaccount.LoginRequest; import com.scriptopia.demo.dto.localaccount.LoginResponse; import com.scriptopia.demo.dto.localaccount.RegisterRequest; -import com.scriptopia.demo.dto.localaccount.changePasswordRequest; +import com.scriptopia.demo.dto.localaccount.ChangePasswordRequest; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.JwtProvider; @@ -126,7 +126,15 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer } @Transactional - public void changePassword(changePasswordRequest request) { + public void changePassword(Long userId, ChangePasswordRequest request) { + LocalAccount localAccount = localAccountRepository.findByUserId(userId).orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + + if (!passwordEncoder.matches(request.getOldPassword(), localAccount.getPassword())) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + + localAccount.setPassword(passwordEncoder.encode(request.getNewPassword())); } diff --git a/src/main/java/com/scriptopia/demo/utils/JwtProvider.java b/src/main/java/com/scriptopia/demo/utils/JwtProvider.java index 25c4f656..856fc42b 100644 --- a/src/main/java/com/scriptopia/demo/utils/JwtProvider.java +++ b/src/main/java/com/scriptopia/demo/utils/JwtProvider.java @@ -26,8 +26,8 @@ public String createAccessToken(Long userId, List roles) { Instant now = Instant.now(); return Jwts.builder() .setIssuer(props.issuer()) - .setSubject(String.valueOf(userId)) - .claim("roles",roles) + .setSubject(String.valueOf(userId)) // userId를 subject로 + .claim("roles", roles) .setId(UUID.randomUUID().toString()) .setIssuedAt(Date.from(now)) .setExpiration(Date.from(now.plusSeconds(props.accessExpSeconds()))) @@ -86,4 +86,8 @@ public boolean isValid(String token) { } } + public String getEmail(String token) { + return parse(token).getBody().getSubject(); + } + } From b26eb32fa679e6a76596d6aa39ea503fe52310db Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 21:49:55 +0900 Subject: [PATCH 044/527] feat: set global API endpoint and update some endpoints global prefix : /api/v1 --- .../demo/controller/GameSessionController.java | 10 +++++----- .../scriptopia/demo/controller/TagDefController.java | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 46210ec8..d2d635d0 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -8,32 +8,32 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api") +@RequestMapping("/game-session") @RequiredArgsConstructor public class GameSessionController { private final GameSessionService gameSessionService; - @PostMapping("/game-session") + @PostMapping public ResponseEntity createGameSession(@RequestHeader("X-User-ID") Long id) { // 게임 세션 정보 저장 return gameSessionService.saveGameSession(id); } // 정보 불러오기 - @GetMapping("/game-session") + @GetMapping public ResponseEntity loadGameSession(@RequestHeader("X-User-ID") Long id) { return gameSessionService.getGameSession(id); } // 수정 - @PutMapping("/game-session") + @PutMapping public ResponseEntity updateGameSession(@RequestHeader("X-User-ID") Long id) { return gameSessionService.updateGameSession(id); } // 삭제 - @DeleteMapping("/game-session") + @DeleteMapping public void deleteGameSession(@RequestHeader("X-User-ID") Long id) { gameSessionService.deleteGameSession(id); } diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index dc28ead8..2ece02c7 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -7,17 +7,17 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@RestController +@RestController("/tags") @RequiredArgsConstructor public class TagDefController { private final TagDefService tagDefService; - @PostMapping("/add-tag") + @PostMapping public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, @RequestHeader("X-USER-ID")Long id) { return tagDefService.addTagName(req, id); } - @DeleteMapping("/remove-tag") + @DeleteMapping public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, @RequestHeader("X-USER-ID")Long id) { return tagDefService.removeTagName(req, id); } From 6257269ef0b27af3747020d080e0c486b746b353 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 22:25:08 +0900 Subject: [PATCH 045/527] feat: add role-based endpoint access control --- .../scriptopia/demo/config/JwtAuthFilter.java | 4 +- .../demo/config/SecurityConfig.java | 4 +- .../demo/controller/AuthController.java | 72 ++++++++++--------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index e819f1ed..1dd16af4 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -26,12 +26,12 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, throws ServletException, IOException { String uri = req.getRequestURI(); - // 로그인/회원가입 제외, 나머지 요청은 JWT 체크 - if (uri.startsWith("/auth/login") || uri.startsWith("/auth/register")) { + if (uri.startsWith("/api/v1/public")) { chain.doFilter(req, res); return; } + String authHeader = req.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index fae6b162..890f3a76 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -43,7 +43,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { })) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**").permitAll() + .requestMatchers("/public/**").permitAll() + .requestMatchers("/user/**").hasAnyAuthority("USER", "ADMIN") + .requestMatchers("/admin/**").hasAnyAuthority("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 01153aa3..f722520a 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -19,7 +19,6 @@ import java.util.List; @RestController -@RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final LocalAccountService localAccountService; @@ -31,14 +30,45 @@ public class AuthController { private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; + @PostMapping("/public/auth/login") + public ResponseEntity login( + @RequestBody @Valid LoginRequest req, + HttpServletRequest request, + HttpServletResponse response + ) { + + return ResponseEntity.ok(localAccountService.login(req, request, response)); + } - @PostMapping("/send-code") + @PostMapping("/user/auth/logout") + public ResponseEntity logout( + @CookieValue(name = RT_COOKIE, required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken != null && !refreshToken.isBlank()) { + refreshTokenService.logout(refreshToken); + } + response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/public/auth/register") + public ResponseEntity register( + @RequestBody @Valid RegisterRequest registerRequest + ) { + localAccountService.register(registerRequest); + return ResponseEntity.status(201).build(); + + } + + + @PostMapping("/public/auth/send-code") public ResponseEntity sendCode(@RequestParam String email) { localAccountService.sendVerificationCode(email); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } - @PostMapping("/verify-code") + @PostMapping("/public/auth/verify-code") public ResponseEntity verifyCode(@RequestParam String email, @RequestParam String code) { boolean success = localAccountService.verifyCode(email, code); @@ -49,7 +79,9 @@ public ResponseEntity verifyCode(@RequestParam String email, } } - @PostMapping("/password/change") + + + @PatchMapping("/user/auth/password/change") public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Authentication authentication) { @@ -60,27 +92,9 @@ public ResponseEntity changePassword(@RequestBody ChangePasswordRequest return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); } - @PostMapping("/register") - public ResponseEntity register( - @RequestBody @Valid RegisterRequest registerRequest - ) { - localAccountService.register(registerRequest); - return ResponseEntity.status(201).build(); - - } - - @PostMapping("/login") - public ResponseEntity login( - @RequestBody @Valid LoginRequest req, - HttpServletRequest request, - HttpServletResponse response - ) { - - return ResponseEntity.ok(localAccountService.login(req, request, response)); - } // 쿠키 기반 리프레시 - @PostMapping("/token/refresh") + @PostMapping("/user/auth/token/refresh") public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, @RequestParam(required = false) String deviceId @@ -98,17 +112,7 @@ public ResponseEntity refresh( .body(new RefreshResponse(pair.accessToken(), props.accessExpSeconds())); } - @PostMapping("/logout") - public ResponseEntity logout( - @CookieValue(name = RT_COOKIE, required = false) String refreshToken, - HttpServletResponse response - ) { - if (refreshToken != null && !refreshToken.isBlank()) { - refreshTokenService.logout(refreshToken); - } - response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); - return ResponseEntity.noContent().build(); - } + From 64b13c20b10a58e8f660efc3036fd3e2467098c3 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 23:00:45 +0900 Subject: [PATCH 046/527] feat: add ErrorCode enum for Custom exception --- .../com/scriptopia/demo/domain/ErrorCode.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/ErrorCode.java diff --git a/src/main/java/com/scriptopia/demo/domain/ErrorCode.java b/src/main/java/com/scriptopia/demo/domain/ErrorCode.java new file mode 100644 index 00000000..967d9e27 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/ErrorCode.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + //Auth + AUTH_401("AUTH_401", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED); + + private final String code; + private final String message; + private final HttpStatus status; +} From 10ea386f39322201e8e4605e93d51c0effedba57 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Tue, 26 Aug 2025 23:03:28 +0900 Subject: [PATCH 047/527] feat: Implement Custom exception class --- .../demo/exception/CustomException.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/exception/CustomException.java diff --git a/src/main/java/com/scriptopia/demo/exception/CustomException.java b/src/main/java/com/scriptopia/demo/exception/CustomException.java new file mode 100644 index 00000000..0d9835b6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/CustomException.java @@ -0,0 +1,16 @@ +package com.scriptopia.demo.exception; + +import com.scriptopia.demo.domain.ErrorCode; +import lombok.Getter; + + +@Getter +public class CustomException extends RuntimeException { + private final ErrorCode errorCode; + + public CustomException(final ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + +} From 2770be84c0668045cc4732c1383ade5a3f25e433 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 01:48:14 +0900 Subject: [PATCH 048/527] feature/56-history page no-offset service add --- .DS_Store | Bin 0 -> 6148 bytes .../demo/dto/history/HistoryPageResponse.java | 26 ++++++++++++++++++ .../demo/repository/HistoryRepository.java | 7 ++++- .../demo/service/HistoryService.java | 14 ++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .DS_Store create mode 100644 src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..42166a68392d38d4f9b5dee3c6b0bd607b3d7a33 GIT binary patch literal 6148 zcmeHKJ5B>p3>-s>NHi%ZDBl&h!755lkOKrM0fYz%N(A**oQtC|{s?KcfsO`^C41iU zdfwAcv0ekP`P=pum;sp59r58|Z2sJRWEYh&BAsX4bTz>_K$hr`2&@e0#ZNzE~o%@f3~uuo)$W=SO`)oR4Bq%+^Dt}E;llMbul!|G*(dY*Eaec v-D^JSZd?b2A=)u9+A%lYj&GtU>zc3myesSzgU)==iTW9EU1U<=uNC+Lxh)y6 literal 0 HcmV?d00001 diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java new file mode 100644 index 00000000..5fd64fc8 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.dto.history; + +import com.scriptopia.demo.domain.History; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class HistoryPageResponse { + private Long id; + private String title; + private Long score; + private String thumbnail_url; + private LocalDateTime created_at; + + public static HistoryPageResponse from(History h) { + HistoryPageResponse dto = new HistoryPageResponse(); + dto.setId(h.getId()); + dto.setTitle(h.getTitle()); + dto.setScore(h.getScore()); + dto.setThumbnail_url(h.getThumbnailUrl()); + dto.setCreated_at(h.getCreatedAt()); + + return dto; + } +} diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java index ecf2d243..1dc854b1 100644 --- a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -1,8 +1,13 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.History; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -public interface HistoryRepository extends JpaRepository { +public interface HistoryRepository extends JpaRepository { + Page findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long lastId, Pageable pageable); + + Page findByUserIdOrderByIdDesc(Long userId, Pageable pageable); } diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 81038ac6..0ff0b3da 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -4,12 +4,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.domain.History; import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.history.HistoryRequest; import com.scriptopia.demo.repository.HistoryRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.bson.Document; import org.bson.types.ObjectId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; @@ -120,4 +123,15 @@ private JsonNode asJson(Document doc) { try { return objectMapper.readTree(doc.toJson()); } catch (Exception e) { throw new RuntimeException("Mongo Document → JsonNode 변환 실패", e); } } + + @Transactional(readOnly = true) + public ResponseEntity> fetchMyHisotry(Long userId, Long lastId, int size) { + PageRequest pr = PageRequest.of(0, size); + Page page; + + if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(userId, pr); + else page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(userId, lastId, pr); + + return ResponseEntity.ok(page.getContent().stream().map(HistoryPageResponse::from).toList()); + } } From 11ebfaec41d60b2416bd91e89301affc5719dfa6 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 02:12:13 +0900 Subject: [PATCH 049/527] UserHistoryController add, get Page, no-offset --- .env | 2 ++ .../demo/controller/HistoryController.java | 3 +++ .../controller/UserHistoryController.java | 24 +++++++++++++++++++ src/main/resources/application.yml | 4 +++- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 src/main/java/com/scriptopia/demo/controller/UserHistoryController.java diff --git a/.env b/.env new file mode 100644 index 00000000..59ea747e --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +SPRING_MAIL_NAME=scriptopia.kr@gmail.com +SPRING_MAIL_PASSWORD=zfleptrvjuszgijx diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index 82544e49..66c3a146 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.history.HistoryRequest; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.service.HistoryService; @@ -7,6 +8,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/games") @RequiredArgsConstructor diff --git a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java new file mode 100644 index 00000000..9b76796c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java @@ -0,0 +1,24 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.history.HistoryPageResponse; +import com.scriptopia.demo.dto.history.HistoryResponse; +import com.scriptopia.demo.service.HistoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserHistoryController { + private final HistoryService historyService; + + @GetMapping("/history") + public ResponseEntity> getHistory(@RequestHeader("X-User-ID") Long userId, + @RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "10") int size) { + return historyService.fetchMyHisotry(userId, lastId, size); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ec58179c..e5575e8b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] datasource: url: jdbc:postgresql://localhost:5432/scriptopia username: root # PostgreSQL에서 설정 계정 @@ -6,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: show_sql: true From 431e5d00603fdc1edc71c7321b835453b9dd5413 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 15:25:28 +0900 Subject: [PATCH 050/527] feat: Implement GlobalExceptionHandler for centralized exception handling --- .../demo/dto/exception/ErrorResponse.java | 14 ++++++++++ .../demo/exception/CustomException.java | 5 +++- .../demo/{domain => exception}/ErrorCode.java | 3 ++- .../exception/GlobalExceptionHandler.java | 26 +++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java rename src/main/java/com/scriptopia/demo/{domain => exception}/ErrorCode.java (89%) create mode 100644 src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java b/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java new file mode 100644 index 00000000..d5134437 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.exception; + +import lombok.Getter; + +@Getter + +public class ErrorResponse { + private final String message; + + public ErrorResponse(String message) { + this.message = message; + } + +} diff --git a/src/main/java/com/scriptopia/demo/exception/CustomException.java b/src/main/java/com/scriptopia/demo/exception/CustomException.java index 0d9835b6..8d7d2743 100644 --- a/src/main/java/com/scriptopia/demo/exception/CustomException.java +++ b/src/main/java/com/scriptopia/demo/exception/CustomException.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.exception; -import com.scriptopia.demo.domain.ErrorCode; import lombok.Getter; @@ -13,4 +12,8 @@ public CustomException(final ErrorCode errorCode) { this.errorCode = errorCode; } + public ErrorCode getErrorCode() { + return errorCode; + } + } diff --git a/src/main/java/com/scriptopia/demo/domain/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java similarity index 89% rename from src/main/java/com/scriptopia/demo/domain/ErrorCode.java rename to src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 967d9e27..51f817d1 100644 --- a/src/main/java/com/scriptopia/demo/domain/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.domain; +package com.scriptopia.demo.exception; import lombok.AllArgsConstructor; import lombok.Getter; @@ -14,4 +14,5 @@ public enum ErrorCode { private final String code; private final String message; private final HttpStatus status; + } diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..855d7001 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import com.scriptopia.demo.dto.exception.ErrorResponse; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(final CustomException e) { + ErrorCode errorCode = e.getErrorCode(); + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ErrorResponse(errorCode.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("예상치 못한 오류가 발생했습니다.")); + } +} From ca10db5b800389bfd250788cd8f43dc565671e43 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 15:47:25 +0900 Subject: [PATCH 051/527] feature/61-game-shared-service add --- .../scriptopia/demo/domain/SharedGame.java | 25 ++++++++++- .../dto/sharedgame/SharedGameRequest.java | 15 +++++++ .../demo/service/SharedGameService.java | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java create mode 100644 src/main/java/com/scriptopia/demo/service/SharedGameService.java diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index 640a59e1..c272f0e0 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -1,7 +1,9 @@ package com.scriptopia.demo.domain; +import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import jakarta.persistence.*; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; @@ -10,6 +12,7 @@ @Entity @Getter @Setter +@NoArgsConstructor public class SharedGame { @Id @GeneratedValue @@ -20,10 +23,28 @@ public class SharedGame { private User user; private String thumbnailUrl; - private Long recommend; - private Long totalPlayed; + private Long recommend = 0L; + private Long totalPlayed = 0L; + + @Column(columnDefinition = "TEXT") private String title; + + @Column(columnDefinition = "TEXT") private String worldView; + + @Column(columnDefinition = "TEXT") private String backgroundStory; private LocalDateTime sharedAt; + + public static SharedGame from(User user, History h) { + SharedGame game = new SharedGame(); + game.user = user; + game.thumbnailUrl = h.getThumbnailUrl(); + game.title = h.getTitle(); + game.worldView = h.getWorldView(); + game.backgroundStory = h.getBackgroundStory(); + game.sharedAt = LocalDateTime.now(); + + return game; + } } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java new file mode 100644 index 00000000..929e6a14 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.sharedgame; + +import com.scriptopia.demo.domain.User; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class SharedGameRequest { + private Long userId; + private String thumbnail_url; + private String title; + private String world_view; + private String background_story; +} diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java new file mode 100644 index 00000000..80bdde2e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -0,0 +1,42 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.History; +import com.scriptopia.demo.domain.SharedGame; +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; +import com.scriptopia.demo.repository.HistoryRepository; +import com.scriptopia.demo.repository.SharedGameRepository; +import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.utils.JwtProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SharedGameService { + private final SharedGameRepository sharedGameRepository; + private final HistoryRepository historyRepository; + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @Transactional + public ResponseEntity saveSharedGame(String header, Long historyId) { + Long userId = jwtProvider.getUserId(header); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new RuntimeException("History not found")); + + if(!history.getUser().getId().equals(userId)) { + return ResponseEntity.status(403).body("not your history"); + } + + SharedGame sharedGame = SharedGame.from(user, history); + return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); + } +} From 8f6707521a34d6b9e50033dff16951aeec50816e Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 15:49:38 +0900 Subject: [PATCH 052/527] feature/61-game-shared-save service add --- src/main/java/com/scriptopia/demo/service/SharedGameService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 80bdde2e..c2e2de66 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -38,5 +38,6 @@ public ResponseEntity saveSharedGame(String header, Long historyId) { SharedGame sharedGame = SharedGame.from(user, history); return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); + } } From efa3fa9f8ff6630cb0c0a7433b2c2355f4a32ac8 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 15:51:57 +0900 Subject: [PATCH 053/527] feat:add error codes for possible login --- .../java/com/scriptopia/demo/exception/CustomException.java | 6 +----- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/CustomException.java b/src/main/java/com/scriptopia/demo/exception/CustomException.java index 8d7d2743..264340d8 100644 --- a/src/main/java/com/scriptopia/demo/exception/CustomException.java +++ b/src/main/java/com/scriptopia/demo/exception/CustomException.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.exception; -import lombok.Getter; +import lombok.Getter; @Getter public class CustomException extends RuntimeException { @@ -12,8 +12,4 @@ public CustomException(final ErrorCode errorCode) { this.errorCode = errorCode; } - public ErrorCode getErrorCode() { - return errorCode; - } - } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 51f817d1..331e2132 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -9,8 +9,11 @@ public enum ErrorCode { //Auth - AUTH_401("AUTH_401", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED); + A_401001("AUTH_401001", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED), + A_403001("AUTH_403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + //User + U_400001("USER_400001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED); private final String code; private final String message; private final HttpStatus status; From 9245dfb88113909cb8e6c97f5d7ffbe08014c821 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 15:52:31 +0900 Subject: [PATCH 054/527] feature/61-game-shared-save service, DTO add --- src/main/java/com/scriptopia/demo/service/SharedGameService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index c2e2de66..6b841bca 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -39,5 +39,6 @@ public ResponseEntity saveSharedGame(String header, Long historyId) { SharedGame sharedGame = SharedGame.from(user, history); return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); + } } From aa9b96011b632865350a4551e54cc334e877a933 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 16:02:59 +0900 Subject: [PATCH 055/527] feature/61-game-shared-save controller add --- .../demo/controller/SharedGameController.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/SharedGameController.java diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java new file mode 100644 index 00000000..7ab89ab6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -0,0 +1,20 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.domain.SharedGame; +import com.scriptopia.demo.service.SharedGameService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class SharedGameController { + private final SharedGameService sharedGameService; + + @PostMapping("/share/{id}") + public ResponseEntity share(@RequestHeader(value = "Authorization")String token, + @PathVariable Long Id) { + return sharedGameService.saveSharedGame(token, Id); + } +} From 7e9f5b64805ca528f356e7b3ca9227d1d1e803c6 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 16:27:10 +0900 Subject: [PATCH 056/527] feat: add exception handling for login validation --- .../demo/dto/exception/ErrorResponse.java | 16 ++++++++-- .../demo/dto/localaccount/LoginRequest.java | 9 ++++++ .../scriptopia/demo/exception/ErrorCode.java | 17 +++++++--- .../exception/GlobalExceptionHandler.java | 32 +++++++++++++++++-- .../demo/service/LocalAccountService.java | 10 ++++-- src/main/resources/application.yml | 8 +++++ 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java b/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java index d5134437..2165d980 100644 --- a/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/exception/ErrorResponse.java @@ -1,14 +1,26 @@ package com.scriptopia.demo.dto.exception; +import com.scriptopia.demo.exception.ErrorCode; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter - public class ErrorResponse { + private final String code; private final String message; + private final HttpStatus status; - public ErrorResponse(String message) { + public ErrorResponse(String code, String message, HttpStatus status) { + this.code = code; this.message = message; + this.status = status; + } + + public ErrorResponse(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.status = errorCode.getStatus(); } + } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java index 37864e51..a4d85fe1 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.dto.localaccount; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,8 +10,15 @@ @AllArgsConstructor @NoArgsConstructor public class LoginRequest { + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") private String password; + + @NotBlank(message = "디바이스 식별값이 필요합니다.") private String deviceId; } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 331e2132..1acd60e5 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -8,12 +8,19 @@ @AllArgsConstructor public enum ErrorCode { - //Auth - A_401001("AUTH_401001", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED), - A_403001("AUTH_403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - //User - U_400001("USER_400001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED); + //400 Bad Request + REQ_400_INVALID_BODY("REQ_400", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), + REQ_400_INVALID_EMAIL_FORMAT("REQ_400_EMAIL","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), + + //401 Unauthorized + AUTH_401_INVALID_CREDENTIALS("AUTH_401_CRED","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + + //403 Forbidden + AUTH_403_ROLE_FORBIDDEN("AUTH_403_ROLE", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + + // + GEN_500("GEN_500", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; private final HttpStatus status; diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 855d7001..d1e2dd84 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -1,26 +1,54 @@ package com.scriptopia.demo.exception; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import com.scriptopia.demo.dto.exception.ErrorResponse; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.HashMap; +import java.util.Map; + @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + var fieldError = e.getBindingResult().getFieldError(); + + ErrorCode errorCode; + if (fieldError != null && "email".equals(fieldError.getField())) { + errorCode = ErrorCode.AUTH_401_INVALID_CREDENTIALS; + } else { + errorCode = ErrorCode.REQ_400_INVALID_BODY; + } + + Map body = new HashMap<>(); + body.put("code", errorCode.getCode()); + body.put("message", fieldError != null ? fieldError.getDefaultMessage() : errorCode.getMessage()); + body.put("status", errorCode.getStatus()); + + return ResponseEntity.status(errorCode.getStatus()) + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } + @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(final CustomException e) { ErrorCode errorCode = e.getErrorCode(); + return ResponseEntity .status(errorCode.getStatus()) - .body(new ErrorResponse(errorCode.getMessage())); + .body(new ErrorResponse(errorCode)); } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { + return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("예상치 못한 오류가 발생했습니다.")); + .body(new ErrorResponse(ErrorCode.GEN_500)); } } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 1089a782..8078f5e1 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -6,6 +6,8 @@ import com.scriptopia.demo.dto.localaccount.LoginResponse; import com.scriptopia.demo.dto.localaccount.RegisterRequest; import com.scriptopia.demo.dto.localaccount.ChangePasswordRequest; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.JwtProvider; @@ -100,11 +102,15 @@ public void register(RegisterRequest request) { @Transactional public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpServletResponse response) { + + + LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) - .orElseThrow(() -> new IllegalArgumentException("아이디 혹은 비밀번호를 잘못 입력했습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.AUTH_401_INVALID_CREDENTIALS)); + if (!passwordEncoder.matches(req.getPassword(), localAccount.getPassword())) { - throw new IllegalArgumentException("아이디 혹은 비밀번호를 잘못 입력했습니다."); + throw new CustomException(ErrorCode.AUTH_401_INVALID_CREDENTIALS); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ec58179c..ad45962b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,9 @@ spring: show_sql: true format_sql: true database-platform: org.hibernate.dialect.PostgreSQLDialect + web: + resources: + add-mappings: true data: mongodb: @@ -35,3 +38,8 @@ auth: refresh-exp-seconds: 1209600 secret: ${JWT_SECRET} +server: + error: + whitelabel: + enabled: false # 톰캣/화이트라벨 HTML 끄기(우리가 직접 분기하므로) + From 3d7bd38a2dd2302f1202e9cbcea469285c8b9cf9 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 16:48:56 +0900 Subject: [PATCH 057/527] feat: add exception handling for email verification code sending --- .../demo/controller/AuthController.java | 5 +-- .../dto/localaccount/SendCodeRequest.java | 17 ++++++++++ .../scriptopia/demo/exception/ErrorCode.java | 7 ++-- .../exception/GlobalExceptionHandler.java | 32 +++++++++---------- 4 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index f722520a..615d250b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -63,8 +64,8 @@ public ResponseEntity register( @PostMapping("/public/auth/send-code") - public ResponseEntity sendCode(@RequestParam String email) { - localAccountService.sendVerificationCode(email); + public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest sendCodeRequest) { + localAccountService.sendVerificationCode(sendCodeRequest.getEmail()); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java new file mode 100644 index 00000000..881698e4 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SendCodeRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; +} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 1acd60e5..9dacbece 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -10,7 +10,8 @@ public enum ErrorCode { //400 Bad Request - REQ_400_INVALID_BODY("REQ_400", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), + E_400("E_400", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), + REQ_400_MISSING_EMAIL("REQ_400_EMAIL_REQUIRED", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), REQ_400_INVALID_EMAIL_FORMAT("REQ_400_EMAIL","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), //401 Unauthorized @@ -19,8 +20,8 @@ public enum ErrorCode { //403 Forbidden AUTH_403_ROLE_FORBIDDEN("AUTH_403_ROLE", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - // - GEN_500("GEN_500", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + //500 INTERNAL_SERVER_ERROR + E_500("E_500", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; private final HttpStatus status; diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index d1e2dd84..48cf2f17 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -1,38 +1,36 @@ package com.scriptopia.demo.exception; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import com.scriptopia.demo.dto.exception.ErrorResponse; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.HashMap; -import java.util.Map; +import java.util.Objects; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { - var fieldError = e.getBindingResult().getFieldError(); + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldError(); + + ErrorCode errorCode = ErrorCode.E_400; // 기본값 - ErrorCode errorCode; if (fieldError != null && "email".equals(fieldError.getField())) { - errorCode = ErrorCode.AUTH_401_INVALID_CREDENTIALS; - } else { - errorCode = ErrorCode.REQ_400_INVALID_BODY; + if (Objects.equals(fieldError.getCode(), "NotBlank")) { + errorCode = ErrorCode.REQ_400_MISSING_EMAIL; + } else if (Objects.equals(fieldError.getCode(), "Email")) { + errorCode = ErrorCode.REQ_400_INVALID_EMAIL_FORMAT; + } } - Map body = new HashMap<>(); - body.put("code", errorCode.getCode()); - body.put("message", fieldError != null ? fieldError.getDefaultMessage() : errorCode.getMessage()); - body.put("status", errorCode.getStatus()); - return ResponseEntity.status(errorCode.getStatus()) - .contentType(MediaType.APPLICATION_JSON) - .body(body); + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ErrorResponse(errorCode)); } @ExceptionHandler(CustomException.class) @@ -49,6 +47,6 @@ public ResponseEntity handleGeneralException(Exception ex) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse(ErrorCode.GEN_500)); + .body(new ErrorResponse(ErrorCode.E_500)); } } From edc36a42eaddaecc6631a68b6d797303da134692 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 16:53:54 +0900 Subject: [PATCH 058/527] Feature/63-shared-game-delete --- .../demo/service/SharedGameService.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 6b841bca..83f85674 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -38,7 +38,22 @@ public ResponseEntity saveSharedGame(String header, Long historyId) { SharedGame sharedGame = SharedGame.from(user, history); return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); + } + @Transactional + public void deletesharedGame(String header, Long sharedId) { + Long userId = jwtProvider.getUserId(header); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + SharedGame game = sharedGameRepository.findById(sharedId) + .orElseThrow(() -> new RuntimeException("Shared game not found")); + + if(!game.getUser().getId().equals(userId)) { // 공유된 게임과 로그인한 사용자가 아닌 경우 + new RuntimeException("User not your history"); + } + sharedGameRepository.delete(game); } } From d17b9af22f87aba28dbed96f615a4489b35e855d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 16:55:48 +0900 Subject: [PATCH 059/527] refactor: update error code enum structure for consistency --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 8 ++++---- .../scriptopia/demo/exception/GlobalExceptionHandler.java | 4 ++-- .../com/scriptopia/demo/service/LocalAccountService.java | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9dacbece..9174edf0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -11,14 +11,14 @@ public enum ErrorCode { //400 Bad Request E_400("E_400", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), - REQ_400_MISSING_EMAIL("REQ_400_EMAIL_REQUIRED", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), - REQ_400_INVALID_EMAIL_FORMAT("REQ_400_EMAIL","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), + E_400_MISSING_EMAIL("E_400_EMAIL_REQUIRED", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_EMAIL_FORMAT("E_400_EMAIL_INVALID","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), //401 Unauthorized - AUTH_401_INVALID_CREDENTIALS("AUTH_401_CRED","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_INVALID_CREDENTIALS("E_401_CRED","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden - AUTH_403_ROLE_FORBIDDEN("AUTH_403_ROLE", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + E_403_ROLE_FORBIDDEN("E_403_ROLE", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), //500 INTERNAL_SERVER_ERROR E_500("E_500", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 48cf2f17..11f10b22 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -21,9 +21,9 @@ public ResponseEntity handleValidation(MethodArgumentNotValidExce if (fieldError != null && "email".equals(fieldError.getField())) { if (Objects.equals(fieldError.getCode(), "NotBlank")) { - errorCode = ErrorCode.REQ_400_MISSING_EMAIL; + errorCode = ErrorCode.E_400_MISSING_EMAIL; } else if (Objects.equals(fieldError.getCode(), "Email")) { - errorCode = ErrorCode.REQ_400_INVALID_EMAIL_FORMAT; + errorCode = ErrorCode.E_400_INVALID_EMAIL_FORMAT; } } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 8078f5e1..c51a1024 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -106,11 +106,11 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) - .orElseThrow(() -> new CustomException(ErrorCode.AUTH_401_INVALID_CREDENTIALS)); + .orElseThrow(() -> new CustomException(ErrorCode.E_401_INVALID_CREDENTIALS)); if (!passwordEncoder.matches(req.getPassword(), localAccount.getPassword())) { - throw new CustomException(ErrorCode.AUTH_401_INVALID_CREDENTIALS); + throw new CustomException(ErrorCode.E_401_INVALID_CREDENTIALS); } From d58cabc58e96a670037458e57bc1357104398bf1 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 27 Aug 2025 17:08:12 +0900 Subject: [PATCH 060/527] feature/63-shared-game delete api add --- .../scriptopia/demo/controller/SharedGameController.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 7ab89ab6..b51c99ac 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -17,4 +17,10 @@ public ResponseEntity share(@RequestHeader(value = "Authorization")String tok @PathVariable Long Id) { return sharedGameService.saveSharedGame(token, Id); } + + @DeleteMapping("/share/{id}") + public void delete(@RequestHeader(value = "Authorization")String token, + @PathVariable Long gameId) { + sharedGameService.deletesharedGame(token, gameId); + } } From 62871d15128704a072ebf3b87eb9bcb9ed889e70 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 27 Aug 2025 17:21:20 +0900 Subject: [PATCH 061/527] feat: update error code notation --- .../com/scriptopia/demo/exception/ErrorCode.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9174edf0..84ebf4ce 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -10,18 +10,18 @@ public enum ErrorCode { //400 Bad Request - E_400("E_400", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), - E_400_MISSING_EMAIL("E_400_EMAIL_REQUIRED", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_EMAIL_FORMAT("E_400_EMAIL_INVALID","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), + E_400("E400000", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), + E_400_MISSING_EMAIL("E400001", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_EMAIL_FORMAT("E400002","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), //401 Unauthorized - E_401_INVALID_CREDENTIALS("E_401_CRED","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden - E_403_ROLE_FORBIDDEN("E_403_ROLE", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), //500 INTERNAL_SERVER_ERROR - E_500("E_500", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; private final HttpStatus status; From 9f51e75caf6e26081bf70b235eee8e6b7dfcefea Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 29 Aug 2025 00:20:28 +0900 Subject: [PATCH 062/527] feature/63-history-controller test complete --- .../scriptopia/demo/controller/HistoryController.java | 11 +++++++---- .../demo/controller/UserHistoryController.java | 11 +++++++---- src/main/resources/application.yml | 4 ++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index 66c3a146..b941d4cd 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -6,12 +6,13 @@ import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController -@RequestMapping("/games") +@RequestMapping("/users/games") @RequiredArgsConstructor public class HistoryController { private final HistoryService historyService; @@ -20,9 +21,11 @@ public class HistoryController { * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ - @PostMapping("/{id}/history/{sid}") - public ResponseEntity addHistory(@PathVariable Long id, @PathVariable String sid) { - return historyService.createHistory(id, sid); + @PostMapping("/{sid}/history") + public ResponseEntity addHistory(@PathVariable String sid, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return historyService.createHistory(userId, sid); } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ diff --git a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java index 9b76796c..94c72d0f 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java @@ -5,20 +5,23 @@ import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController -@RequestMapping("/users") +@RequestMapping("/public") @RequiredArgsConstructor public class UserHistoryController { private final HistoryService historyService; @GetMapping("/history") - public ResponseEntity> getHistory(@RequestHeader("X-User-ID") Long userId, - @RequestParam(required = false) Long lastId, - @RequestParam(defaultValue = "10") int size) { + public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "10") int size, + @RequestParam Long userId) { +// Long userId = Long.valueOf(authentication.getName()); + return historyService.fetchMyHisotry(userId, lastId, size); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e5575e8b..00377e1a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,7 @@ +server: + servlet: + context-path: /api/v1 + spring: config: import: optional:file:.env[.properties] From 5b89ef738d67498733c8c8425f4e01046c953b90 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 29 Aug 2025 02:38:20 +0900 Subject: [PATCH 063/527] feature/63-shared-game test api complete --- .../demo/controller/SharedGameController.java | 19 +++++++++++-------- .../demo/service/SharedGameService.java | 18 +++++++----------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index b51c99ac..7428ca0e 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -4,6 +4,7 @@ import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController @@ -12,15 +13,17 @@ public class SharedGameController { private final SharedGameService sharedGameService; - @PostMapping("/share/{id}") - public ResponseEntity share(@RequestHeader(value = "Authorization")String token, - @PathVariable Long Id) { - return sharedGameService.saveSharedGame(token, Id); + @PostMapping("/share/{hid}") + public ResponseEntity share(Authentication authentication, @PathVariable Long hid) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.saveSharedGame(userId, hid); } - @DeleteMapping("/share/{id}") - public void delete(@RequestHeader(value = "Authorization")String token, - @PathVariable Long gameId) { - sharedGameService.deletesharedGame(token, gameId); + @DeleteMapping("/share/{gameid}") + public void delete(Authentication authentication, @PathVariable Long gameid) { + Long userId = Long.valueOf(authentication.getName()); + + sharedGameService.deletesharedGame(userId, gameid); } } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 83f85674..94c13c08 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -23,16 +23,14 @@ public class SharedGameService { private final UserRepository userRepository; @Transactional - public ResponseEntity saveSharedGame(String header, Long historyId) { - Long userId = jwtProvider.getUserId(header); - - User user = userRepository.findById(userId) + public ResponseEntity saveSharedGame(Long Id, Long historyId) { + User user = userRepository.findById(Id) .orElseThrow(() -> new RuntimeException("User not found")); History history = historyRepository.findById(historyId) .orElseThrow(() -> new RuntimeException("History not found")); - if(!history.getUser().getId().equals(userId)) { + if(!history.getUser().getId().equals(Id)) { return ResponseEntity.status(403).body("not your history"); } @@ -41,17 +39,15 @@ public ResponseEntity saveSharedGame(String header, Long historyId) { } @Transactional - public void deletesharedGame(String header, Long sharedId) { - Long userId = jwtProvider.getUserId(header); - - User user = userRepository.findById(userId) + public void deletesharedGame(Long id, Long sharedId) { + User user = userRepository.findById(id) .orElseThrow(() -> new RuntimeException("User not found")); SharedGame game = sharedGameRepository.findById(sharedId) .orElseThrow(() -> new RuntimeException("Shared game not found")); - if(!game.getUser().getId().equals(userId)) { // 공유된 게임과 로그인한 사용자가 아닌 경우 - new RuntimeException("User not your history"); + if(!game.getUser().getId().equals(user.getId())) { // 공유된 게임과 로그인한 사용자가 아닌 경우 + throw new RuntimeException("User not your history"); } sharedGameRepository.delete(game); From 77e7a54e8cb8bf5e05e59bf8bc1a52f445114c64 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 21:43:48 +0900 Subject: [PATCH 064/527] feat: add error code E_400_INVALID_CODE for verifyCode --- .../scriptopia/demo/controller/AuthController.java | 13 ++++--------- .../com/scriptopia/demo/exception/ErrorCode.java | 3 +++ .../demo/exception/GlobalExceptionHandler.java | 1 + .../demo/service/LocalAccountService.java | 14 +++++++++++--- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 615d250b..e952d84f 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -58,11 +58,9 @@ public ResponseEntity register( @RequestBody @Valid RegisterRequest registerRequest ) { localAccountService.register(registerRequest); - return ResponseEntity.status(201).build(); - + return ResponseEntity.ok("회원가입에 성공했습니다."); } - @PostMapping("/public/auth/send-code") public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest sendCodeRequest) { localAccountService.sendVerificationCode(sendCodeRequest.getEmail()); @@ -72,12 +70,9 @@ public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest sendC @PostMapping("/public/auth/verify-code") public ResponseEntity verifyCode(@RequestParam String email, @RequestParam String code) { - boolean success = localAccountService.verifyCode(email, code); - if (success) { - return ResponseEntity.ok("이메일 인증이 완료되었습니다."); - } else { - return ResponseEntity.badRequest().body("인증번호가 올바르지 않거나 만료되었습니다."); - } + localAccountService.verifyCode(email, code); + return ResponseEntity.ok("이메일 인증이 완료되었습니다."); + } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 84ebf4ce..8a56b07d 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -11,8 +11,10 @@ public enum ErrorCode { //400 Bad Request E_400("E400000", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), + E_400_MISSING_EMAIL("E400001", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_EMAIL_FORMAT("E400002","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), + E_400_INVALID_CODE("E400003", "인증 코드는 6자리 숫자여야 합니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), @@ -22,6 +24,7 @@ public enum ErrorCode { //500 INTERNAL_SERVER_ERROR E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + private final String code; private final String message; private final HttpStatus status; diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 11f10b22..fd79c413 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -42,6 +42,7 @@ public ResponseEntity handleCustomException(final CustomException .body(new ErrorResponse(errorCode)); } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index c51a1024..a5cc68f9 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -27,6 +27,8 @@ import java.util.Locale; import java.util.concurrent.TimeUnit; +import static org.thymeleaf.util.StringUtils.length; + @Service @Transactional @RequiredArgsConstructor @@ -52,15 +54,21 @@ public void sendVerificationCode(String email) { mailService.sendVerificationCode(email, code); } - public boolean verifyCode(String email, String inputCode) { + public void verifyCode(String email, String inputCode) { + + if (length(inputCode) != 6){ + throw new CustomException(ErrorCode.E_400_INVALID_CODE); + } + String savedCode = redisTemplate.opsForValue().get("email:verify:" + email); + if (savedCode != null && savedCode.equals(inputCode)) { // 인증 완료 후 30분 유지 redisTemplate.opsForValue().set("email:verified:" + email, "true", 30, TimeUnit.MINUTES); redisTemplate.delete("email:verify:" + email); // 코드 제거 - return true; } - return false; + + } @Transactional From 253bb851eb435f30e1c80ddcffb1440233625984 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 21:46:04 +0900 Subject: [PATCH 065/527] feat: add error code E_401_CODE_MISMATCH for verify code --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 ++ .../java/com/scriptopia/demo/service/LocalAccountService.java | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 8a56b07d..3eb6c4b0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -19,6 +19,8 @@ public enum ErrorCode { //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index a5cc68f9..7ba4fec4 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -67,6 +67,9 @@ public void verifyCode(String email, String inputCode) { redisTemplate.opsForValue().set("email:verified:" + email, "true", 30, TimeUnit.MINUTES); redisTemplate.delete("email:verify:" + email); // 코드 제거 } + else{ + throw new CustomException(ErrorCode.E_401_CODE_MISMATCH); + } } From 069b9f0114fe5aab32a19687a3b9d3c481bc0631 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 29 Aug 2025 21:51:56 +0900 Subject: [PATCH 066/527] feat: create function --- .../demo/controller/AuctionController.java | 31 +++++++++ .../scriptopia/demo/domain/Settlement.java | 3 +- .../com/scriptopia/demo/domain/TradeType.java | 6 ++ .../exception/auction/AuctionException.java | 7 ++ .../auction/AuctionNotFoundException.java | 7 ++ .../auction/InsufficientPiaException.java | 7 ++ .../auction/SelfPurchaseException.java | 7 ++ .../demo/service/AuctionService.java | 66 +++++++++++++++++-- 8 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/domain/TradeType.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index cdce2c8b..0f1aa84d 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -4,8 +4,14 @@ import com.scriptopia.demo.dto.auction.AuctionRequest; import com.scriptopia.demo.dto.auction.TradeResponse; import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.exception.auction.AuctionException; +import com.scriptopia.demo.exception.auction.AuctionNotFoundException; +import com.scriptopia.demo.exception.auction.InsufficientPiaException; +import com.scriptopia.demo.exception.auction.SelfPurchaseException; import com.scriptopia.demo.service.AuctionService; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -34,4 +40,29 @@ public ResponseEntity getTrades( } + + @PostMapping("/{auctionId}/purchase") + public ResponseEntity purchaseItem( + @PathVariable String auctionId, + @RequestHeader("token") String userId) { + + try { + String result = auctionService.purchaseItem(auctionId, userId); + return ResponseEntity.ok(result); + + } catch (InsufficientPiaException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (SelfPurchaseException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (AuctionNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); + } catch (AuctionException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + } + + + + + } diff --git a/src/main/java/com/scriptopia/demo/domain/Settlement.java b/src/main/java/com/scriptopia/demo/domain/Settlement.java index 4707a0f5..2a77bb1d 100644 --- a/src/main/java/com/scriptopia/demo/domain/Settlement.java +++ b/src/main/java/com/scriptopia/demo/domain/Settlement.java @@ -23,7 +23,8 @@ public class Settlement { private ItemDef itemDef; - private TradeStatus tradeStatus; + @Enumerated(EnumType.STRING) + private TradeType tradeType; private Long price; diff --git a/src/main/java/com/scriptopia/demo/domain/TradeType.java b/src/main/java/com/scriptopia/demo/domain/TradeType.java new file mode 100644 index 00000000..1743d5c6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/TradeType.java @@ -0,0 +1,6 @@ +package com.scriptopia.demo.domain; + +public enum TradeType { + BUY, + SELL +} diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java new file mode 100644 index 00000000..536bb482 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class AuctionException extends RuntimeException { + public AuctionException(String message) { + super(message); + } +} diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java new file mode 100644 index 00000000..9bfce889 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class AuctionNotFoundException extends AuctionException { + public AuctionNotFoundException() { + super("해당 경매가 존재하지 않습니다."); + } +} diff --git a/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java b/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java new file mode 100644 index 00000000..beb78821 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class InsufficientPiaException extends AuctionException { + public InsufficientPiaException() { + super("금액이 부족합니다."); + } +} diff --git a/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java b/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java new file mode 100644 index 00000000..d5541b02 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class SelfPurchaseException extends AuctionException { + public SelfPurchaseException() { + super("자기 물건은 구매할 수 없습니다."); + } +} diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index f2c2d1a4..d80f9b71 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -1,14 +1,17 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.Auction; -import com.scriptopia.demo.domain.TradeStatus; -import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.auction.AuctionRequest; import com.scriptopia.demo.dto.auction.AuctionItemResponse; import com.scriptopia.demo.dto.auction.TradeResponse; import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.exception.auction.AuctionNotFoundException; +import com.scriptopia.demo.exception.auction.InsufficientPiaException; +import com.scriptopia.demo.exception.auction.SelfPurchaseException; import com.scriptopia.demo.repository.AuctionRepository; +import com.scriptopia.demo.repository.SettlementRepository; import com.scriptopia.demo.repository.UserItemRepository; +import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -29,6 +32,8 @@ public class AuctionService { private final AuctionRepository auctionRepository; private final UserItemRepository userItemRepository; + private final UserRepository userRepository; + private final SettlementRepository settlementRepository; @Transactional public String createAuction(AuctionRequest requestDto, String userId) { @@ -78,7 +83,7 @@ public String createAuction(AuctionRequest requestDto, String userId) { - public TradeResponse getTrades(TradeFilterRequest request){ + public TradeResponse getTrades(TradeFilterRequest request) { int page = request.getPageIndex().intValue(); int size = request.getPageSize().intValue(); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); @@ -101,7 +106,6 @@ public TradeResponse getTrades(TradeFilterRequest request){ } - List items = auctionPage.stream() .map(a -> { AuctionItemResponse dto = new AuctionItemResponse(); @@ -167,15 +171,65 @@ public TradeResponse getTrades(TradeFilterRequest request){ return response; - } + @Transactional + public String purchaseItem(String auctionIdStr, String userIdStr) { + Long auctionId = Long.parseLong(auctionIdStr); + Long userId = Long.parseLong(userIdStr); + + // 1. 거래소 정보 조회 + Auction auction = auctionRepository.findById(auctionId) + .orElseThrow(AuctionNotFoundException::new); + + User buyer = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User seller = auction.getUserItem().getUser(); + // 2. 자기 물건 구매 금지 + if (buyer.getId().equals(seller.getId())) { + throw new SelfPurchaseException(); + } + // 3. 금액 확인 + if (buyer.getPia() < auction.getPrice()) { + throw new InsufficientPiaException(); + } + // 4. 금액 처리 + buyer.setPia(buyer.getPia() - auction.getPrice()); + + // 5. UserItem 상태 변경 + UserItem userItem = auction.getUserItem(); + userItem.setTradeStatus(TradeStatus.SOLD); + + // 6. Settlement 기록 추가 + Settlement buyerSettlement = new Settlement(); + buyerSettlement.setUser(buyer); + buyerSettlement.setItemDef(userItem.getItemDef()); + buyerSettlement.setPrice(auction.getPrice()); + buyerSettlement.setTradeType(TradeType.BUY); + buyerSettlement.setCreatedAt(LocalDateTime.now()); + buyerSettlement.setSettledAt(null); + settlementRepository.save(buyerSettlement); + + Settlement sellerSettlement = new Settlement(); + sellerSettlement.setUser(seller); + sellerSettlement.setItemDef(userItem.getItemDef()); + sellerSettlement.setPrice(auction.getPrice()); + sellerSettlement.setTradeType(TradeType.SELL); + sellerSettlement.setCreatedAt(LocalDateTime.now()); + sellerSettlement.setSettledAt(null); + settlementRepository.save(sellerSettlement); + + // 7. 경매 테이블에서 삭제 + auctionRepository.delete(auction); + + return "구매 완료"; + } } From f0c15e6c07db1130c3c1eea92f7a49a71c6faee2 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 22:25:00 +0900 Subject: [PATCH 067/527] feat: add email error code for verify code --- .../demo/controller/AuthController.java | 13 ++++++------ .../dto/localaccount/VerifyCodeRequest.java | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index e952d84f..fe7e41b5 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -55,22 +55,21 @@ public ResponseEntity logout( @PostMapping("/public/auth/register") public ResponseEntity register( - @RequestBody @Valid RegisterRequest registerRequest + @RequestBody @Valid RegisterRequest request ) { - localAccountService.register(registerRequest); + localAccountService.register(request); return ResponseEntity.ok("회원가입에 성공했습니다."); } @PostMapping("/public/auth/send-code") - public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest sendCodeRequest) { - localAccountService.sendVerificationCode(sendCodeRequest.getEmail()); + public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { + localAccountService.sendVerificationCode(request.getEmail()); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } @PostMapping("/public/auth/verify-code") - public ResponseEntity verifyCode(@RequestParam String email, - @RequestParam String code) { - localAccountService.verifyCode(email, code); + public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { + localAccountService.verifyCode(request.getEmail(), request.getCode()); return ResponseEntity.ok("이메일 인증이 완료되었습니다."); } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java new file mode 100644 index 00000000..34273b77 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class VerifyCodeRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + private String code; + + +} From dd310117d2516ffabb3d3df50085e9bf7693ac4d Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 22:56:11 +0900 Subject: [PATCH 068/527] feat :add error code E_412_EMAIL_NOT_VERIFIED for register --- .../java/com/scriptopia/demo/controller/AuthController.java | 1 - src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 5 ++++- .../com/scriptopia/demo/service/LocalAccountService.java | 4 +--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index fe7e41b5..ed9f1e71 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -8,7 +8,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import jakarta.validation.constraints.Email; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 3eb6c4b0..9bd288a0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -24,7 +24,10 @@ public enum ErrorCode { //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - //500 INTERNAL_SERVER_ERROR + //412 Precondition Failed + E_412_EMAIL_NOT_VERIFIED("E412001", "이메일 인증이 필요합니다.",HttpStatus.PRECONDITION_FAILED), + + //500 Internal Server Error E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 7ba4fec4..f4c3590a 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -114,8 +114,6 @@ public void register(RegisterRequest request) { @Transactional public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpServletResponse response) { - - LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) .orElseThrow(() -> new CustomException(ErrorCode.E_401_INVALID_CREDENTIALS)); @@ -170,7 +168,7 @@ private static String normalizeEmail(String email) { private static void validateParams(String verified, String email, String rawPassword, String nickname) { if (verified == null || !verified.equals("true")) { - throw new RuntimeException("이메일 인증을 먼저 완료해야 합니다."); + throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); } if (email == null || email.isBlank()) { From 44ac6960bd2110392fd8e5cb7212c9d2c5738487 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 23:20:10 +0900 Subject: [PATCH 069/527] feat: add error codes for register E_400_MISSING_PASSWORD E_400_PASSWORD_SIZE E_400_PASSWORD_COMPLEXITY E_400_MISSING_NICKNAME E_412_EMAIL_NOT_VERIFIED --- .../dto/localaccount/RegisterRequest.java | 1 - .../scriptopia/demo/exception/ErrorCode.java | 5 +++ .../exception/GlobalExceptionHandler.java | 33 +++++++++++++++---- .../demo/service/LocalAccountService.java | 1 - 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java index b4e3305d..aaebc6db 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java @@ -17,7 +17,6 @@ public class RegisterRequest { @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") @Pattern( regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9bd288a0..b4d2b85d 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -15,6 +15,11 @@ public enum ErrorCode { E_400_MISSING_EMAIL("E400001", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_EMAIL_FORMAT("E400002","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), E_400_INVALID_CODE("E400003", "인증 코드는 6자리 숫자여야 합니다.", HttpStatus.BAD_REQUEST), + E_400_MISSING_PASSWORD("E400004", "비밀번호는 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), + E_400_PASSWORD_SIZE("E400005", "비밀번호는 8~20자리여야 합니다.", HttpStatus.BAD_REQUEST), + E_400_PASSWORD_COMPLEXITY("E400006", "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다.", HttpStatus.BAD_REQUEST), + E_400_MISSING_NICKNAME("E400007", "닉네임은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), + //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index fd79c413..ad5400d2 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -17,13 +17,32 @@ public class GlobalExceptionHandler { public ResponseEntity handleValidation(MethodArgumentNotValidException e) { FieldError fieldError = e.getBindingResult().getFieldError(); - ErrorCode errorCode = ErrorCode.E_400; // 기본값 - - if (fieldError != null && "email".equals(fieldError.getField())) { - if (Objects.equals(fieldError.getCode(), "NotBlank")) { - errorCode = ErrorCode.E_400_MISSING_EMAIL; - } else if (Objects.equals(fieldError.getCode(), "Email")) { - errorCode = ErrorCode.E_400_INVALID_EMAIL_FORMAT; + ErrorCode errorCode = ErrorCode.E_400; + if (fieldError == null || fieldError.getDefaultMessage() == null) { + // 기본값 + } + else { + if ("email".equals(fieldError.getField())){ + if (Objects.equals(fieldError.getCode(), "NotBlank")) { + errorCode = ErrorCode.E_400_MISSING_EMAIL; + } else if (Objects.equals(fieldError.getCode(), "Email")) { + errorCode = ErrorCode.E_400_INVALID_EMAIL_FORMAT; + } + } + else if ("password".equals(fieldError.getField())){ + if (Objects.equals(fieldError.getCode(), "NotBlank")) { + errorCode = ErrorCode.E_400_MISSING_PASSWORD; + } else if (Objects.equals(fieldError.getCode(), "Size")) { + errorCode = ErrorCode.E_400_PASSWORD_SIZE; + } + else if (Objects.equals(fieldError.getCode(), "Pattern")) { + errorCode = ErrorCode.E_400_PASSWORD_COMPLEXITY; + } + } + else if ("nickname".equals(fieldError.getField())){ + if (Objects.equals(fieldError.getCode(), "NotBlank")) { + errorCode = ErrorCode.E_400_MISSING_NICKNAME; + } } } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index f4c3590a..13afe790 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -175,7 +175,6 @@ private static void validateParams(String verified, String email, String rawPass throw new IllegalArgumentException("이메일을 입력해주세요."); } - if (rawPassword == null || rawPassword.isBlank()) { throw new IllegalArgumentException("비밀번호를 입력해주세요."); } From e46c52fed6bb265700e3217de89dc2f73717f023 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 23:32:01 +0900 Subject: [PATCH 070/527] feat: add error codes for register E_409_EMAIL_TAKEN E_409_NICKNAME_TAKEN --- build.gradle | 1 - .../java/com/scriptopia/demo/exception/ErrorCode.java | 4 ++++ .../scriptopia/demo/service/LocalAccountService.java | 10 ---------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/build.gradle b/build.gradle index 3b61d33a..24047715 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-validation' // DB 관련 runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index b4d2b85d..75c5ce97 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -29,6 +29,10 @@ public enum ErrorCode { //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + //409 Conflict + E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), + E_409_NICKNAME_TAKEN("E409002", "이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT), + //412 Precondition Failed E_412_EMAIL_NOT_VERIFIED("E412001", "이메일 인증이 필요합니다.",HttpStatus.PRECONDITION_FAILED), diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 13afe790..e9d3630b 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -171,16 +171,6 @@ private static void validateParams(String verified, String email, String rawPass throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); } - if (email == null || email.isBlank()) { - throw new IllegalArgumentException("이메일을 입력해주세요."); - } - - if (rawPassword == null || rawPassword.isBlank()) { - throw new IllegalArgumentException("비밀번호를 입력해주세요."); - } - if (nickname == null || nickname.isBlank()) { - throw new IllegalArgumentException("닉네임 입력해주세요."); - } } private void isAvailable(String email, String nickname) { From ba937e70d4a76d6e2f076fdb732cf197f8c756d8 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Fri, 29 Aug 2025 23:35:08 +0900 Subject: [PATCH 071/527] feat: handle duplicate email and nickname on signup --- .../demo/service/LocalAccountService.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index e9d3630b..b317bc01 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -78,7 +78,10 @@ public void verifyCode(String email, String inputCode) { public void register(RegisterRequest request) { String normalizedEmail = normalizeEmail(request.getEmail()); String verified = redisTemplate.opsForValue().get("email:verified:" + normalizedEmail); - validateParams(verified, normalizedEmail, request.getPassword(), request.getNickname()); + + if (verified == null || !verified.equals("true")) { + throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); + } isAvailable(normalizedEmail, request.getNickname()); @@ -165,21 +168,14 @@ private static String normalizeEmail(String email) { return email.trim().toLowerCase(Locale.ROOT); } - private static void validateParams(String verified, String email, String rawPassword, String nickname) { - - if (verified == null || !verified.equals("true")) { - throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); - } - - } private void isAvailable(String email, String nickname) { if (localAccountRepository.existsByEmail(email)) { - throw new DuplicateEmailException(email); + throw new CustomException(ErrorCode.E_409_EMAIL_TAKEN); } if (userRepository.existsByNickname(nickname)) { - throw new DuplicateNicknameException(nickname); + throw new CustomException(ErrorCode.E_409_NICKNAME_TAKEN); } } From ef846676c5b2e5a7e59bdc4f03cdba4d50cd33d7 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 00:10:13 +0900 Subject: [PATCH 072/527] feat: granular refresh token rotation errors E_404_REFRESH_NOT_FOUND when session is missing E_409_REFRESH_REUSE_DETECTED on hash mismatch E_500_TOKEN_HASHING_FAILED when hashing fails --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 8 +++++++- .../scriptopia/demo/service/RefreshTokenService.java | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 75c5ce97..9667d340 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -28,16 +28,22 @@ public enum ErrorCode { //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + E_403_DEVICE_MISMATCH("E403002", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), + + //404 Not Found + E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), E_409_NICKNAME_TAKEN("E409002", "이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT), + E_409_REFRESH_REUSE_DETECTED("E409003", "리프레시 토큰 재사용이 감지되었습니다.", HttpStatus.CONFLICT), //412 Precondition Failed E_412_EMAIL_NOT_VERIFIED("E412001", "이메일 인증이 필요합니다.",HttpStatus.PRECONDITION_FAILED), //500 Internal Server Error - E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_TOKEN_HASHING_FAILED("E_500001","리프레쉬 토큰 해싱에 실패했습니다.",HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; diff --git a/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java b/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java index b950c3db..fdaf701d 100644 --- a/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java +++ b/src/main/java/com/scriptopia/demo/service/RefreshTokenService.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.record.RefreshSession; import com.scriptopia.demo.repository.RefreshRepository; @@ -47,16 +49,16 @@ public TokenPair rotate(String refreshToken, String expectedDeviceId, List new IllegalArgumentException("Refresh not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_REFRESH_NOT_FOUND)); String input = sha256Base64Url(refreshToken); if (!passwordEncoder.matches(input, saved.tokenHash())) { - throw new IllegalArgumentException("Refresh hash mismatch"); + throw new CustomException(ErrorCode.E_409_REFRESH_REUSE_DETECTED); } @@ -91,7 +93,7 @@ private static String sha256Base64Url(String s) { byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); } catch (Exception e) { - throw new IllegalStateException("Hashing failed", e); + throw new CustomException(ErrorCode.E_500_TOKEN_HASHING_FAILED); } } } From dea8bb8c5d01a98fdea7faf46a7c5c9557d403a7 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 00:23:04 +0900 Subject: [PATCH 073/527] feat: handle missing/expired refresh token --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 5 ++--- .../scriptopia/demo/exception/GlobalExceptionHandler.java | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9667d340..f6859597 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -19,13 +19,12 @@ public enum ErrorCode { E_400_PASSWORD_SIZE("E400005", "비밀번호는 8~20자리여야 합니다.", HttpStatus.BAD_REQUEST), E_400_PASSWORD_COMPLEXITY("E400006", "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_NICKNAME("E400007", "닉네임은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), - + E_400_REFRESH_REQUIRED("E400008", "리프레쉬 토큰이 필요합니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), - E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), - + E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), E_403_DEVICE_MISMATCH("E403002", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index ad5400d2..1273553a 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.exception; +import io.jsonwebtoken.ExpiredJwtException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import com.scriptopia.demo.dto.exception.ErrorResponse; @@ -69,4 +70,10 @@ public ResponseEntity handleGeneralException(Exception ex) { .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse(ErrorCode.E_500)); } + + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity handleExpired(ExpiredJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); + } } From 3d09f33d2ffd0e4414798d61438c8459aa28af31 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 00:53:15 +0900 Subject: [PATCH 074/527] feat: add password change validations and error handling E_400_PASSWORD_CONFIRM_MISMATCH when confirmation doesn't match E_400_PASSWORD_WHITESPACE when password contains whitespace E_401_CURRENT_PASSWORD_MISMATCH for incorrect current password E_404_USER_NOT_FOUND when user is missing E_409_PASSWORD_SAME_AS_OLD when new password equals old --- .../demo/controller/AuthController.java | 2 +- .../localaccount/ChangePasswordRequest.java | 2 + .../scriptopia/demo/exception/ErrorCode.java | 7 ++- .../exception/GlobalExceptionHandler.java | 4 +- .../demo/service/LocalAccountService.java | 43 ++++++++++++------- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index ed9f1e71..2885ae08 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -76,7 +76,7 @@ public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest r @PatchMapping("/user/auth/password/change") - public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, + public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java index 834ef1c1..0bf5d5d6 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java @@ -28,4 +28,6 @@ public class ChangePasswordRequest { message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." ) private String newPassword; + + private String confirmPassword; } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index f6859597..c0abe974 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -11,7 +11,6 @@ public enum ErrorCode { //400 Bad Request E_400("E400000", "잘못된 요청 형식입니다.", HttpStatus.BAD_REQUEST), - E_400_MISSING_EMAIL("E400001", "이메일은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_EMAIL_FORMAT("E400002","이메일 형식이 올바르지 않습니다.",HttpStatus.BAD_REQUEST), E_400_INVALID_CODE("E400003", "인증 코드는 6자리 숫자여야 합니다.", HttpStatus.BAD_REQUEST), @@ -20,22 +19,28 @@ public enum ErrorCode { E_400_PASSWORD_COMPLEXITY("E400006", "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_NICKNAME("E400007", "닉네임은 필수 입력 값입니다.", HttpStatus.BAD_REQUEST), E_400_REFRESH_REQUIRED("E400008", "리프레쉬 토큰이 필요합니다.", HttpStatus.BAD_REQUEST), + E_400_PASSWORD_CONFIRM_MISMATCH("E400009", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.",HttpStatus.BAD_REQUEST), + E_400_PASSWORD_WHITESPACE("E400010","비밀번호에 공백을 포함할 수 없습니다.",HttpStatus.BAD_REQUEST), //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), + //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), E_403_DEVICE_MISMATCH("E403002", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), + E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), E_409_NICKNAME_TAKEN("E409002", "이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT), E_409_REFRESH_REUSE_DETECTED("E409003", "리프레시 토큰 재사용이 감지되었습니다.", HttpStatus.CONFLICT), + E_409_PASSWORD_SAME_AS_OLD("E409004","기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.",HttpStatus.CONFLICT), //412 Precondition Failed E_412_EMAIL_NOT_VERIFIED("E412001", "이메일 인증이 필요합니다.",HttpStatus.PRECONDITION_FAILED), diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 1273553a..127ffbf3 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -30,7 +30,9 @@ public ResponseEntity handleValidation(MethodArgumentNotValidExce errorCode = ErrorCode.E_400_INVALID_EMAIL_FORMAT; } } - else if ("password".equals(fieldError.getField())){ + else if ("password".equals(fieldError.getField()) || + "oldPassword".equals(fieldError.getField()) || + "newPassword".equals(fieldError.getField())){ if (Objects.equals(fieldError.getCode(), "NotBlank")) { errorCode = ErrorCode.E_400_MISSING_PASSWORD; } else if (Objects.equals(fieldError.getCode(), "Size")) { diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index b317bc01..7c769ae5 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import static org.thymeleaf.util.StringUtils.length; @@ -47,6 +48,8 @@ public class LocalAccountService { private static final String COOKIE_SAMESITE = "None"; private final MailService mailService; + private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); + @Transactional public void sendVerificationCode(String email) { String code = String.format("%06d", (int)(Math.random() * 999999)); @@ -83,6 +86,11 @@ public void register(RegisterRequest request) { throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); } + // 공백 검증 + if (WS.matcher(request.getPassword()).find()) { + throw new CustomException(ErrorCode.E_400_PASSWORD_WHITESPACE); + } + isAvailable(normalizedEmail, request.getNickname()); //user 객체 생성 @@ -145,11 +153,28 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer @Transactional public void changePassword(Long userId, ChangePasswordRequest request) { - LocalAccount localAccount = localAccountRepository.findByUserId(userId).orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + //현재, 변경 비밀번호 불일치 + if(!request.getNewPassword().equals(request.getConfirmPassword())){ + throw new CustomException(ErrorCode.E_400_PASSWORD_CONFIRM_MISMATCH); + } + + // 공백 검증 + if (WS.matcher(request.getNewPassword()).find()) { + throw new CustomException(ErrorCode.E_400_PASSWORD_WHITESPACE); + } + + LocalAccount localAccount = localAccountRepository.findByUserId(userId).orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + //현재 암호 불일치 if (!passwordEncoder.matches(request.getOldPassword(), localAccount.getPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + throw new CustomException(ErrorCode.E_401_CURRENT_PASSWORD_MISMATCH); + } + + //이전과 동일한 암호 + if (passwordEncoder.matches(request.getNewPassword(), localAccount.getPassword())) { + throw new CustomException(ErrorCode.E_409_PASSWORD_SAME_AS_OLD); } localAccount.setPassword(passwordEncoder.encode(request.getNewPassword())); @@ -162,7 +187,7 @@ public List getRoles(Long userId) { return List.of(Role.USER.toString()); } - //대 소문자 구별 + //소문자로 변경 private static String normalizeEmail(String email) { if (email == null) return null; return email.trim().toLowerCase(Locale.ROOT); @@ -180,18 +205,6 @@ private void isAvailable(String email, String nickname) { } - public static class DuplicateEmailException extends RuntimeException { - public DuplicateEmailException(String email) { - super("이미 존재하는 이메일입니다.: " + email); - } - } - - public static class DuplicateNicknameException extends RuntimeException { - public DuplicateNicknameException(String nickname) { - super("이미 존재하는 닉네임입니다.: " + nickname); - } - } - public ResponseCookie refreshCookie(String value) { return ResponseCookie.from(RT_COOKIE, value) .httpOnly(true) From 8b9e83d07786d260068d7fb7d7482c118f6ad2aa Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 01:09:26 +0900 Subject: [PATCH 075/527] fix: modify logout return value --- .../java/com/scriptopia/demo/controller/AuthController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 2885ae08..7543632b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -41,7 +41,7 @@ public ResponseEntity login( } @PostMapping("/user/auth/logout") - public ResponseEntity logout( + public ResponseEntity logout( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { @@ -49,7 +49,7 @@ public ResponseEntity logout( refreshTokenService.logout(refreshToken); } response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok("로그아웃 되었습니다."); } @PostMapping("/public/auth/register") From a97fdff4cb34b1a5781168a44e76fafe904fad8b Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 02:32:35 +0900 Subject: [PATCH 076/527] feat: add verify email request dto --- .../demo/controller/AuthController.java | 1 + .../dto/localaccount/verifyEmailRequest.java | 17 +++++++++++++++++ src/main/resources/application.yml | 9 ++++----- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 7543632b..8165301b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -30,6 +30,7 @@ public class AuthController { private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; + @PostMapping("/public/auth/login") public ResponseEntity login( @RequestBody @Valid LoginRequest req, diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java new file mode 100644 index 00000000..8c069e3d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class verifyEmailRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a95d1ac6..1dd0abac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,9 @@ server: servlet: context-path: /api/v1 + error: + whitelabel: + enabled: false # 톰캣/화이트라벨 HTML 끄기(우리가 직접 분기하므로) spring: config: @@ -12,7 +15,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true @@ -44,8 +47,4 @@ auth: refresh-exp-seconds: 1209600 secret: ${JWT_SECRET} -server: - error: - whitelabel: - enabled: false # 톰캣/화이트라벨 HTML 끄기(우리가 직접 분기하므로) From 76d6ccc87322280fd2b696be3d849c89aa382f56 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 02:34:46 +0900 Subject: [PATCH 077/527] feat: create end point verify-email --- .../java/com/scriptopia/demo/controller/AuthController.java | 6 ++++++ .../{verifyEmailRequest.java => VerifyEmailRequest.java} | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) rename src/main/java/com/scriptopia/demo/dto/localaccount/{verifyEmailRequest.java => VerifyEmailRequest.java} (92%) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 8165301b..fed8df99 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -31,6 +31,12 @@ public class AuthController { private static final String COOKIE_SAMESITE = "None"; + @PostMapping("/public/auth/verify-email") + public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + + return ResponseEntity.ok("사용 가능한 이메일입니다."); + } + @PostMapping("/public/auth/login") public ResponseEntity login( @RequestBody @Valid LoginRequest req, diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java similarity index 92% rename from src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java rename to src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java index 8c069e3d..7efbd084 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/verifyEmailRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java @@ -9,7 +9,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class verifyEmailRequest { +public class VerifyEmailRequest { @NotBlank(message = "이메일은 필수 입력 값입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") From 8d32574a7105803e463410b7f8c9da3acca5168d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 02:40:15 +0900 Subject: [PATCH 078/527] good point --- .../demo/controller/AuctionController.java | 50 ++++- .../demo/controller/ItemController.java | 13 +- .../com/scriptopia/demo/domain/ItemDef.java | 8 +- .../scriptopia/demo/domain/ItemEffect.java | 12 +- .../scriptopia/demo/domain/Settlement.java | 5 +- .../com/scriptopia/demo/domain/TradeType.java | 6 + .../com/scriptopia/demo/domain/UserItem.java | 3 +- .../demo/dto/auction/AuctionItemResponse.java | 50 +++++ .../demo/dto/auction/AuctionRequest.java | 3 +- .../demo/dto/auction/TradeFilterRequest.java | 23 +++ .../demo/dto/auction/TradeResponse.java | 19 ++ .../demo/dto/develop/ItemDefResponse.java | 26 +++ .../demo/dto/develop/ItemEffectResponse.java | 10 + .../exception/auction/AuctionException.java | 7 + .../auction/AuctionNotFoundException.java | 7 + .../auction/InsufficientPiaException.java | 7 + .../auction/SelfPurchaseException.java | 7 + .../demo/repository/AuctionRepository.java | 61 +++++- .../demo/service/AuctionService.java | 181 +++++++++++++++++- .../demo/service/ItemDefService.java | 68 +++++-- src/main/resources/application.yml | 13 +- 21 files changed, 522 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/domain/TradeType.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java create mode 100644 src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 28ca009e..b11eed38 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,7 +1,16 @@ package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.auction.TradeResponse; +import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.exception.auction.AuctionException; +import com.scriptopia.demo.exception.auction.AuctionNotFoundException; +import com.scriptopia.demo.exception.auction.InsufficientPiaException; +import com.scriptopia.demo.exception.auction.SelfPurchaseException; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -12,13 +21,44 @@ public class AuctionController { private final AuctionService auctionService; + @PostMapping - public ResponseEntity createAuction( - @RequestBody com.scriptopia.demo.dto.auction.AuctionRequest requestDto, - @RequestHeader("token") String userId) { // 헤더에서 userId 가져오기 임시임 + public ResponseEntity createAuction(@RequestBody AuctionRequest dto, + @RequestHeader("token") String userId ){ // 헤더에서 userId 가져오기 임시임 + + return ResponseEntity.ok(auctionService.createAuction(dto, userId)); + } + + + @GetMapping + public ResponseEntity getTrades( + @RequestBody TradeFilterRequest requestDto) { + + TradeResponse response = auctionService.getTrades(requestDto); + return ResponseEntity.ok(response); + + } + + + @PostMapping("/{auctionId}/purchase") + public ResponseEntity purchaseItem( + @PathVariable String auctionId, + @RequestHeader("token") String userId) { + + try { + String result = auctionService.purchaseItem(auctionId, userId); + return ResponseEntity.ok(result); - return ResponseEntity.ok(auctionService.createAuction(requestDto, userId)); + } catch (InsufficientPiaException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (SelfPurchaseException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (AuctionNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); + } catch (AuctionException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } } -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index e8e64a60..3e222488 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,14 +1,11 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.dto.develop.ItemDefResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.service.ItemDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/items") @@ -18,10 +15,12 @@ public class ItemController { private final ItemDefService itemDefService; @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { - ItemDef savedItem = itemDefService.createItem(dto); + public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { + ItemDefResponse savedItem = itemDefService.createItem(dto); return ResponseEntity.ok(savedItem); } + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index 4003c9eb..b1af6513 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -5,6 +5,8 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -39,4 +41,8 @@ public class ItemDef { private LocalDateTime createdAt; private Long price; -} + + @OneToMany(mappedBy = "itemDef", cascade = CascadeType.ALL, orphanRemoval = true) + private List itemEffects = new ArrayList<>(); + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java index ebae0e0e..1244d508 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java @@ -10,18 +10,20 @@ public class ItemEffect { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue private Long id; // FK: ItemDefs @ManyToOne(fetch = FetchType.LAZY) - private ItemDef itemDefs; + private ItemDef itemDef; // FK: EffectGradeDef @ManyToOne(fetch = FetchType.LAZY) private EffectGradeDef effectGradeDef; - // 예를 들어 효과 이름이나 수치 같은 필드가 있다면 여기에 추가 + // 예를 들어 효과 이름 private String effectName; - private int effectValue; -} + + // 아이템 효과 설명 + private String effect_description; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/Settlement.java b/src/main/java/com/scriptopia/demo/domain/Settlement.java index 4707a0f5..3542a89a 100644 --- a/src/main/java/com/scriptopia/demo/domain/Settlement.java +++ b/src/main/java/com/scriptopia/demo/domain/Settlement.java @@ -23,11 +23,12 @@ public class Settlement { private ItemDef itemDef; - private TradeStatus tradeStatus; + @Enumerated(EnumType.STRING) + private TradeType tradeType; private Long price; private LocalDateTime settledAt; private LocalDateTime createdAt; -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/TradeType.java b/src/main/java/com/scriptopia/demo/domain/TradeType.java new file mode 100644 index 00000000..2278c633 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/TradeType.java @@ -0,0 +1,6 @@ +package com.scriptopia.demo.domain; + +public enum TradeType { + BUY, + SELL +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/UserItem.java b/src/main/java/com/scriptopia/demo/domain/UserItem.java index a7a63123..a41de3f8 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserItem.java +++ b/src/main/java/com/scriptopia/demo/domain/UserItem.java @@ -23,6 +23,7 @@ public class UserItem { private int remainingUses; + @Enumerated(EnumType.STRING) private TradeStatus tradeStatus; -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java new file mode 100644 index 00000000..3d09245f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java @@ -0,0 +1,50 @@ +package com.scriptopia.demo.dto.auction; + +import com.scriptopia.demo.domain.TradeStatus; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + + +@Data +public class AuctionItemResponse { + + private Long auctionId; + private Long price; + private LocalDateTime createdAt; + + private UserDto seller; + private ItemDto item; + + @Data + public static class UserDto { + private Long userId; + private String nickname; + } + + @Data + public static class ItemDto { + private Long userItemId; + private Long itemDefId; + private String name; + private String description; + private String picSrc; + private int remainingUses; + private TradeStatus tradeStatus; + private String grade; + private int baseStat; + private int strength; + private int agility; + private int intelligence; + private int luck; + private List effects; + } + + @Data + public static class ItemEffectDto { + private String effectName; + private String effectDescription; + private String grade; + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java index 78c7437f..e6788c9a 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java @@ -6,7 +6,6 @@ @Data public class AuctionRequest { - private String itemDefsId; - private TradeStatus tradeStatus; // ENUM이면 String으로 받아서 변환 + private String itemDefId; // 단수형으로 변경함 private Long price; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java new file mode 100644 index 00000000..7c739f44 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java @@ -0,0 +1,23 @@ +package com.scriptopia.demo.dto.auction; + +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.MainStat; +import lombok.Data; + +import java.util.List; + +@Data +public class TradeFilterRequest { + + private Long pageIndex; // 0부터 시작 + private Long pageSize; // 한 페이지당 아이템 수 + + private String itemName; // 판매 아이템 이름 + private ItemType category; // 아이템 분류 (WEAPON, ARMOR, ARTIFACT, POTION) + private Long minPrice; // 최소 가격 (nullable) + private Long maxPrice; // 최대 가격 (nullable) + private Grade grade; // 아이템 등급 (nullable) + private List effectGrades; // 아이템 효과 등급 필터 (nullable) + private MainStat mainStat; // 주 스탯 (nullable) +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java new file mode 100644 index 00000000..34cf120e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/TradeResponse.java @@ -0,0 +1,19 @@ +// PagedAuctionResponse.java +package com.scriptopia.demo.dto.auction; + +import lombok.Data; +import java.util.List; + +@Data +public class TradeResponse { + private List content; + private PageInfo pageInfo; + + @Data + public static class PageInfo { + private int currentPage; + private int pageSize; + private long totalItems; + private int totalPages; + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java b/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java new file mode 100644 index 00000000..65fba5eb --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.dto.develop; + + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class ItemDefResponse { + private Long id; + private String name; + private String description; + private String picSrc; + private String itemType; // enum 대신 String으로 전달 + private String mainStat; // enum 대신 String + private Integer baseStat; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private Long price; + private LocalDateTime createdAt; + + private List effects; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java b/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java new file mode 100644 index 00000000..d530f75a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java @@ -0,0 +1,10 @@ +package com.scriptopia.demo.dto.develop; + +import lombok.Data; + +@Data +public class ItemEffectResponse { + private String effectName; + private String effectDescription; + private String grade; // enum 대신 String +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java new file mode 100644 index 00000000..0e5485fe --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class AuctionException extends RuntimeException { + public AuctionException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java new file mode 100644 index 00000000..3df4eadc --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class AuctionNotFoundException extends AuctionException { + public AuctionNotFoundException() { + super("해당 경매가 존재하지 않습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java b/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java new file mode 100644 index 00000000..7f58fb28 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class InsufficientPiaException extends AuctionException { + public InsufficientPiaException() { + super("금액이 부족합니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java b/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java new file mode 100644 index 00000000..b1fb6f20 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.exception.auction; + +public class SelfPurchaseException extends AuctionException { + public SelfPurchaseException() { + super("자기 물건은 구매할 수 없습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 71ae01a0..d6b33688 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -1,9 +1,64 @@ package com.scriptopia.demo.repository; -import com.scriptopia.demo.domain.Auction; -import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.domain.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface AuctionRepository extends JpaRepository { boolean existsByUserItem(UserItem userItem); -} + + + // itemName 우선 검색 (연관 테이블 조인) + @Query(""" + SELECT DISTINCT a FROM Auction a + JOIN FETCH a.userItem ui + JOIN FETCH ui.user u + JOIN FETCH ui.itemDef idf + LEFT JOIN FETCH idf.itemEffects ie + LEFT JOIN FETCH ie.effectGradeDef e + WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) + ORDER BY a.createdAt DESC + """) + Page findByItemName(@Param("itemName") String itemName, Pageable pageable); + + + // 필터 조건 검색 (itemName 없는 경우) + @Query(""" + SELECT DISTINCT a + FROM Auction a + JOIN a.userItem ui + JOIN ui.itemDef id + LEFT JOIN id.itemEffects ie + WHERE (:category IS NULL OR id.itemType = :category) + AND (:grade IS NULL OR id.itemGradeDef.grade = :grade) + AND (:minPrice IS NULL OR a.price >= :minPrice) + AND (:maxPrice IS NULL OR a.price <= :maxPrice) + AND (:mainStat IS NULL OR id.mainStat = :mainStat) + AND ( + :effectGrades IS NULL + OR EXISTS ( + SELECT 1 FROM ItemEffect ie2 + WHERE ie2.itemDef = id + AND ie2.effectGradeDef.grade IN :effectGrades + ) + ) +""") + Page findByFilters( + @Param("category") ItemType category, + @Param("grade") Grade grade, + @Param("minPrice") Long minPrice, + @Param("maxPrice") Long maxPrice, + @Param("mainStat") MainStat mainStat, + @Param("effectGrades") List effectGrades, + Pageable pageable + ); + + + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 6ffa120c..f88cae78 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -1,23 +1,39 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.Auction; -import com.scriptopia.demo.domain.TradeStatus; -import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.auction.AuctionItemResponse; +import com.scriptopia.demo.dto.auction.TradeResponse; +import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.exception.auction.AuctionNotFoundException; +import com.scriptopia.demo.exception.auction.InsufficientPiaException; +import com.scriptopia.demo.exception.auction.SelfPurchaseException; import com.scriptopia.demo.repository.AuctionRepository; +import com.scriptopia.demo.repository.SettlementRepository; import com.scriptopia.demo.repository.UserItemRepository; -import jakarta.transaction.Transactional; +import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + import java.time.LocalDateTime; +import java.util.List; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class AuctionService { private final AuctionRepository auctionRepository; private final UserItemRepository userItemRepository; + private final UserRepository userRepository; + private final SettlementRepository settlementRepository; @Transactional public String createAuction(AuctionRequest requestDto, String userId) { @@ -25,7 +41,7 @@ public String createAuction(AuctionRequest requestDto, String userId) { // UUID(String) → Long 변환 (임시) long userItemId; try { - userItemId = Long.parseLong(requestDto.getItemDefsId()); + userItemId = Long.parseLong(requestDto.getItemDefId()); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid UserItem UUID"); } @@ -63,4 +79,157 @@ public String createAuction(AuctionRequest requestDto, String userId) { return "등록 완료: 가격=" + requestDto.getPrice(); } -} + + + + + public TradeResponse getTrades(TradeFilterRequest request) { + int page = request.getPageIndex().intValue(); + int size = request.getPageSize().intValue(); + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page auctionPage; + if (request.getItemName() != null && !request.getItemName().isEmpty()) { + // itemName으로 검색, 연관 테이블까지 fetch + auctionPage = auctionRepository.findByItemName(request.getItemName(), pageable); + } else { + // 이름이 없으면 다른 필터 조건으로 검색 + auctionPage = auctionRepository.findByFilters( + request.getCategory(), + request.getGrade(), + request.getMinPrice(), + request.getMaxPrice(), + request.getMainStat(), + request.getEffectGrades(), + pageable + ); + } + + + List items = auctionPage.stream() + .map(a -> { + AuctionItemResponse dto = new AuctionItemResponse(); + dto.setAuctionId(a.getId()); + dto.setPrice(a.getPrice()); + dto.setCreatedAt(a.getCreatedAt()); + + // seller 정보 + AuctionItemResponse.UserDto userDto = new AuctionItemResponse.UserDto(); + userDto.setUserId(a.getUserItem().getUser().getId()); + userDto.setNickname(a.getUserItem().getUser().getNickname()); + dto.setSeller(userDto); + + // item 정보 + AuctionItemResponse.ItemDto itemDto = new AuctionItemResponse.ItemDto(); + itemDto.setUserItemId(a.getUserItem().getId()); + itemDto.setItemDefId(a.getUserItem().getItemDef().getId()); + itemDto.setName(a.getUserItem().getItemDef().getName()); + itemDto.setDescription(a.getUserItem().getItemDef().getDescription()); + itemDto.setPicSrc(a.getUserItem().getItemDef().getPicSrc()); + itemDto.setRemainingUses(a.getUserItem().getRemainingUses()); + itemDto.setTradeStatus(a.getUserItem().getTradeStatus()); + itemDto.setGrade(String.valueOf(a.getUserItem().getItemDef().getItemGradeDef().getGrade())); + itemDto.setBaseStat(a.getUserItem().getItemDef().getBaseStat()); + itemDto.setStrength(a.getUserItem().getItemDef().getStrength()); + itemDto.setAgility(a.getUserItem().getItemDef().getAgility()); + itemDto.setIntelligence(a.getUserItem().getItemDef().getIntelligence()); + itemDto.setLuck(a.getUserItem().getItemDef().getLuck()); + + // 효과 리스트 → 조건과 관계없이 "모든 효과"를 포함 + // (JPQL에서 한 개만 남았더라도, Hibernate 엔티티를 통해 전체 컬렉션 다시 로딩) + a.getUserItem().getItemDef().getItemEffects().size(); // lazy 초기화 강제 + + List effects = + a.getUserItem().getItemDef().getItemEffects().stream() + .map(e -> { + AuctionItemResponse.ItemEffectDto effDto = new AuctionItemResponse.ItemEffectDto(); + effDto.setEffectName(e.getEffectName()); + effDto.setEffectDescription(e.getEffect_description()); + effDto.setGrade(e.getEffectGradeDef().getGrade().name()); + return effDto; + }) + .toList(); + + itemDto.setEffects(effects); + dto.setItem(itemDto); + + return dto; + }) + .toList(); + + + TradeResponse response = new TradeResponse(); + response.setContent(items); + + TradeResponse.PageInfo pageInfo = new TradeResponse.PageInfo(); + pageInfo.setCurrentPage(auctionPage.getNumber()); // 현재 페이지 + pageInfo.setPageSize(auctionPage.getSize()); // 페이지 당 항목 수 + pageInfo.setTotalPages(auctionPage.getTotalPages()); // 전체 페이지 수 + pageInfo.setTotalItems(auctionPage.getTotalElements()); // 전체 아이템 수 + + response.setPageInfo(pageInfo); + + return response; + + } + + + + @Transactional + public String purchaseItem(String auctionIdStr, String userIdStr) { + Long auctionId = Long.parseLong(auctionIdStr); + Long userId = Long.parseLong(userIdStr); + + // 1. 거래소 정보 조회 + Auction auction = auctionRepository.findById(auctionId) + .orElseThrow(AuctionNotFoundException::new); + + User buyer = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + User seller = auction.getUserItem().getUser(); + + // 2. 자기 물건 구매 금지 + if (buyer.getId().equals(seller.getId())) { + throw new SelfPurchaseException(); + } + + // 3. 금액 확인 + if (buyer.getPia() < auction.getPrice()) { + throw new InsufficientPiaException(); + } + + // 4. 금액 처리 + buyer.setPia(buyer.getPia() - auction.getPrice()); + + // 5. UserItem 상태 변경 + UserItem userItem = auction.getUserItem(); + userItem.setTradeStatus(TradeStatus.SOLD); + + // 6. Settlement 기록 추가 + Settlement buyerSettlement = new Settlement(); + buyerSettlement.setUser(buyer); + buyerSettlement.setItemDef(userItem.getItemDef()); + buyerSettlement.setPrice(auction.getPrice()); + buyerSettlement.setTradeType(TradeType.BUY); + buyerSettlement.setCreatedAt(LocalDateTime.now()); + buyerSettlement.setSettledAt(null); + settlementRepository.save(buyerSettlement); + + Settlement sellerSettlement = new Settlement(); + sellerSettlement.setUser(seller); + sellerSettlement.setItemDef(userItem.getItemDef()); + sellerSettlement.setPrice(auction.getPrice()); + sellerSettlement.setTradeType(TradeType.SELL); + sellerSettlement.setCreatedAt(LocalDateTime.now()); + sellerSettlement.setSettledAt(null); + settlementRepository.save(sellerSettlement); + + // 7. 경매 테이블에서 삭제 + auctionRepository.delete(auction); + + return "구매 완료"; + } + + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index e65eed7c..515fc472 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,39 +1,39 @@ package com.scriptopia.demo.service; - import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.domain.ItemEffect; import com.scriptopia.demo.domain.ItemGradeDef; -import com.scriptopia.demo.dto.items.ItemDefRequest; -import com.scriptopia.demo.dto.items.ItemEffectRequest; +import com.scriptopia.demo.dto.develop.ItemDefResponse; +import com.scriptopia.demo.dto.develop.ItemEffectResponse; +import com.scriptopia.demo.dto.items.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; -import com.scriptopia.demo.repository.ItemEffectRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor -@Transactional +@Transactional(readOnly = true) public class ItemDefService { private final ItemDefRepository itemDefRepository; - private final ItemEffectRepository itemEffectRepository; private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; @Transactional - public ItemDef createItem(ItemDefRequest dto) { - // 1️⃣ ItemGradeDef 조회 + public ItemDefResponse createItem(ItemDefRequest dto) { + // ItemGradeDef 조회 ItemGradeDef gradeDef = itemGradeDefRepository.findById(dto.getItemGradeDefId()) .orElseThrow(() -> new IllegalArgumentException("ItemGradeDef not found")); - // 2️⃣ ItemDef 생성 + // ItemDef 생성 ItemDef itemDef = new ItemDef(); itemDef.setName(dto.getName()); itemDef.setDescription(dto.getDescription()); @@ -49,24 +49,58 @@ public ItemDef createItem(ItemDefRequest dto) { itemDef.setItemGradeDef(gradeDef); itemDef.setCreatedAt(LocalDateTime.now()); - itemDefRepository.save(itemDef); - - // 3️⃣ ItemEffect 생성 + // ItemEffect 생성 if (dto.getEffects() != null) { for (ItemEffectRequest effectDto : dto.getEffects()) { EffectGradeDef effectGradeDef = effectGradeDefRepository.findById(effectDto.getGrade().ordinal() + 1L) .orElseThrow(() -> new IllegalArgumentException("EffectGradeDef not found")); ItemEffect effect = new ItemEffect(); - effect.setItemDefs(itemDef); + effect.setItemDef(itemDef); effect.setEffectGradeDef(effectGradeDef); effect.setEffectName(effectDto.getEffectName()); - effect.setEffectValue(effectDto.getEffectValue()); + effect.setEffect_description(effectDto.getEffectDescription()); - itemEffectRepository.save(effect); + itemDef.getItemEffects().add(effect); } } - return itemDef; + // ItemDef 저장 (cascade로 ItemEffect도 같이 저장) + itemDefRepository.save(itemDef); + + // DTO 변환 후 반환 + return toResponse(itemDef); + } + + // ================== DTO 변환 ================== + private ItemDefResponse toResponse(ItemDef itemDef) { + ItemDefResponse response = new ItemDefResponse(); + response.setId(itemDef.getId()); + response.setName(itemDef.getName()); + response.setDescription(itemDef.getDescription()); + response.setPicSrc(itemDef.getPicSrc()); + response.setItemType(itemDef.getItemType().name()); + response.setMainStat(itemDef.getMainStat().name()); + response.setBaseStat(itemDef.getBaseStat()); + response.setStrength(itemDef.getStrength()); + response.setAgility(itemDef.getAgility()); + response.setIntelligence(itemDef.getIntelligence()); + response.setLuck(itemDef.getLuck()); + response.setPrice(itemDef.getPrice()); + response.setCreatedAt(itemDef.getCreatedAt()); + + List effects = itemDef.getItemEffects().stream() + .map(effect -> { + ItemEffectResponse eResp = new ItemEffectResponse(); + eResp.setEffectName(effect.getEffectName()); + eResp.setEffectDescription(effect.getEffect_description()); + eResp.setGrade(effect.getEffectGradeDef().getGrade().name()); + return eResp; + }) + .collect(Collectors.toList()); + + response.setEffects(effects); + + return response; } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a95d1ac6..098807e8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,9 @@ server: servlet: context-path: /api/v1 + error: + whitelabel: + enabled: false # 톰캣/화이트라벨 HTML 끄기(우리가 직접 분기하므로) spring: config: @@ -12,7 +15,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true @@ -42,10 +45,4 @@ auth: issuer: scriptopia access-exp-seconds: 1800 refresh-exp-seconds: 1209600 - secret: ${JWT_SECRET} - -server: - error: - whitelabel: - enabled: false # 톰캣/화이트라벨 HTML 끄기(우리가 직접 분기하므로) - + secret: ${JWT_SECRET} \ No newline at end of file From e39b72048e016df9fcd1a6f714852ba30bb1546c Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 02:43:00 +0900 Subject: [PATCH 079/527] feat: implement verift email service --- .../demo/service/LocalAccountService.java | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 7c769ae5..99f03912 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -2,10 +2,7 @@ import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.localaccount.LoginRequest; -import com.scriptopia.demo.dto.localaccount.LoginResponse; -import com.scriptopia.demo.dto.localaccount.RegisterRequest; -import com.scriptopia.demo.dto.localaccount.ChangePasswordRequest; +import com.scriptopia.demo.dto.localaccount.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.LocalAccountRepository; @@ -50,6 +47,17 @@ public class LocalAccountService { private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); + + @Transactional + public void verifyEmail(VerifyEmailRequest request) { + String email = request.getEmail(); + + if (localAccountRepository.existsByEmail(email)){ + throw new CustomException(ErrorCode.E_409_EMAIL_TAKEN); + } + + } + @Transactional public void sendVerificationCode(String email) { String code = String.format("%06d", (int)(Math.random() * 999999)); @@ -57,6 +65,7 @@ public void sendVerificationCode(String email) { mailService.sendVerificationCode(email, code); } + @Transactional public void verifyCode(String email, String inputCode) { if (length(inputCode) != 6){ @@ -79,9 +88,16 @@ public void verifyCode(String email, String inputCode) { @Transactional public void register(RegisterRequest request) { - String normalizedEmail = normalizeEmail(request.getEmail()); - String verified = redisTemplate.opsForValue().get("email:verified:" + normalizedEmail); + String email = request.getEmail(); + + //중복 검증 + if (localAccountRepository.existsByEmail(email)){ + throw new CustomException(ErrorCode.E_409_EMAIL_TAKEN); + } + String verified = redisTemplate.opsForValue().get("email:verified:" + email); + + //이메일 인증 검증 if (verified == null || !verified.equals("true")) { throw new CustomException(ErrorCode.E_412_EMAIL_NOT_VERIFIED); } @@ -91,7 +107,7 @@ public void register(RegisterRequest request) { throw new CustomException(ErrorCode.E_400_PASSWORD_WHITESPACE); } - isAvailable(normalizedEmail, request.getNickname()); + isAvailable(email, request.getNickname()); //user 객체 생성 User user = new User(); @@ -106,7 +122,7 @@ public void register(RegisterRequest request) { //localAccount 객체 생성 LocalAccount localAccount = new LocalAccount(); localAccount.setUser(user); - localAccount.setEmail(normalizedEmail); + localAccount.setEmail(email); localAccount.setPassword(passwordEncoder.encode(request.getPassword())); localAccount.setUpdatedAt(LocalDateTime.now()); localAccount.setStatus(UserStatus.UNVERIFIED); @@ -187,12 +203,6 @@ public List getRoles(Long userId) { return List.of(Role.USER.toString()); } - //소문자로 변경 - private static String normalizeEmail(String email) { - if (email == null) return null; - return email.trim().toLowerCase(Locale.ROOT); - } - private void isAvailable(String email, String nickname) { if (localAccountRepository.existsByEmail(email)) { From e22aa9c17f8c2f397af864f5e29324c19785db04 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 30 Aug 2025 02:46:57 +0900 Subject: [PATCH 080/527] feat: connect controller with service for email duplication check --- .../java/com/scriptopia/demo/controller/AuthController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index fed8df99..49f6db80 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -34,6 +34,8 @@ public class AuthController { @PostMapping("/public/auth/verify-email") public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + localAccountService.verifyEmail(request); + return ResponseEntity.ok("사용 가능한 이메일입니다."); } From c5cd81c32ec66db85786c8150de47094c9a03705 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 03:40:25 +0900 Subject: [PATCH 081/527] =?UTF-8?q?refactor:=20=C3=A3delete=20auction/exce?= =?UTF-8?q?ption=20feat:=20add=20custom=20exception=20refactor:=20change?= =?UTF-8?q?=20ActionService=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/controller/AuctionController.java | 17 +++-------------- .../demo/controller/ItemController.java | 2 +- .../scriptopia/demo/exception/ErrorCode.java | 6 ++++++ .../exception/auction/AuctionException.java | 7 ------- .../auction/AuctionNotFoundException.java | 7 ------- .../auction/InsufficientPiaException.java | 7 ------- .../auction/SelfPurchaseException.java | 7 ------- .../scriptopia/demo/service/AuctionService.java | 13 ++++++------- 8 files changed, 16 insertions(+), 50 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java delete mode 100644 src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java delete mode 100644 src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java delete mode 100644 src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index b11eed38..83102728 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -45,20 +45,9 @@ public ResponseEntity purchaseItem( @PathVariable String auctionId, @RequestHeader("token") String userId) { - try { - String result = auctionService.purchaseItem(auctionId, userId); - return ResponseEntity.ok(result); - - } catch (InsufficientPiaException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); - } catch (SelfPurchaseException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); - } catch (AuctionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); - } catch (AuctionException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); - } - } + String result = auctionService.purchaseItem(auctionId, userId); + return ResponseEntity.ok(result); + } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index 3e222488..9c52f371 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/items") +@RequestMapping("/public/items") @RequiredArgsConstructor public class ItemController { diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index c0abe974..533a8f79 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -22,6 +22,11 @@ public enum ErrorCode { E_400_PASSWORD_CONFIRM_MISMATCH("E400009", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.",HttpStatus.BAD_REQUEST), E_400_PASSWORD_WHITESPACE("E400010","비밀번호에 공백을 포함할 수 없습니다.",HttpStatus.BAD_REQUEST), + E_400_INSUFFICIENT_PIA("E400011", "금액이 부족합니다.", HttpStatus.BAD_REQUEST), + E_400_SELF_PURCHASE("E400012", "자기 물건은 구매할 수 없습니다.", HttpStatus.BAD_REQUEST), + + + //401 Unauthorized E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), @@ -35,6 +40,7 @@ public enum ErrorCode { //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), + E_404_AUCTION_NOT_FOUND("E404003", "해당 경매가 존재하지 않습니다.", HttpStatus.NOT_FOUND), //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java deleted file mode 100644 index 0e5485fe..00000000 --- a/src/main/java/com/scriptopia/demo/exception/auction/AuctionException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.scriptopia.demo.exception.auction; - -public class AuctionException extends RuntimeException { - public AuctionException(String message) { - super(message); - } -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java b/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java deleted file mode 100644 index 3df4eadc..00000000 --- a/src/main/java/com/scriptopia/demo/exception/auction/AuctionNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.scriptopia.demo.exception.auction; - -public class AuctionNotFoundException extends AuctionException { - public AuctionNotFoundException() { - super("해당 경매가 존재하지 않습니다."); - } -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java b/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java deleted file mode 100644 index 7f58fb28..00000000 --- a/src/main/java/com/scriptopia/demo/exception/auction/InsufficientPiaException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.scriptopia.demo.exception.auction; - -public class InsufficientPiaException extends AuctionException { - public InsufficientPiaException() { - super("금액이 부족합니다."); - } -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java b/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java deleted file mode 100644 index b1fb6f20..00000000 --- a/src/main/java/com/scriptopia/demo/exception/auction/SelfPurchaseException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.scriptopia.demo.exception.auction; - -public class SelfPurchaseException extends AuctionException { - public SelfPurchaseException() { - super("자기 물건은 구매할 수 없습니다."); - } -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index f88cae78..73ef50da 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -5,9 +5,8 @@ import com.scriptopia.demo.dto.auction.AuctionItemResponse; import com.scriptopia.demo.dto.auction.TradeResponse; import com.scriptopia.demo.dto.auction.TradeFilterRequest; -import com.scriptopia.demo.exception.auction.AuctionNotFoundException; -import com.scriptopia.demo.exception.auction.InsufficientPiaException; -import com.scriptopia.demo.exception.auction.SelfPurchaseException; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.AuctionRepository; import com.scriptopia.demo.repository.SettlementRepository; import com.scriptopia.demo.repository.UserItemRepository; @@ -182,21 +181,21 @@ public String purchaseItem(String auctionIdStr, String userIdStr) { // 1. 거래소 정보 조회 Auction auction = auctionRepository.findById(auctionId) - .orElseThrow(AuctionNotFoundException::new); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); User buyer = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); User seller = auction.getUserItem().getUser(); // 2. 자기 물건 구매 금지 if (buyer.getId().equals(seller.getId())) { - throw new SelfPurchaseException(); + throw new CustomException(ErrorCode.E_400_SELF_PURCHASE); } // 3. 금액 확인 if (buyer.getPia() < auction.getPrice()) { - throw new InsufficientPiaException(); + throw new CustomException(ErrorCode.E_400_INSUFFICIENT_PIA); } // 4. 금액 처리 From 6e1bc80868c7ee1a49a45ce701c1483c13a9dced Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 03:40:59 +0900 Subject: [PATCH 082/527] =?UTF-8?q?refactor:=20=C3=A3delete=20auction/exce?= =?UTF-8?q?ption=20feat:=20add=20custom=20exception=20refactor:=20change?= =?UTF-8?q?=20ActionService=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/scriptopia/demo/controller/AuctionController.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 83102728..f21d4290 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -4,10 +4,6 @@ import com.scriptopia.demo.dto.auction.AuctionRequest; import com.scriptopia.demo.dto.auction.TradeResponse; import com.scriptopia.demo.dto.auction.TradeFilterRequest; -import com.scriptopia.demo.exception.auction.AuctionException; -import com.scriptopia.demo.exception.auction.AuctionNotFoundException; -import com.scriptopia.demo.exception.auction.InsufficientPiaException; -import com.scriptopia.demo.exception.auction.SelfPurchaseException; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; From d99ac83239090ee3a278c5c56d4daac3e11f856c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 03:58:01 +0900 Subject: [PATCH 083/527] feat: AuctionController exception --- .../com/scriptopia/demo/controller/AuctionController.java | 7 +++---- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 3 +-- .../java/com/scriptopia/demo/service/ItemDefService.java | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index f21d4290..b9942f30 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -12,13 +12,12 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/trades") public class AuctionController { private final AuctionService auctionService; - @PostMapping + @PostMapping("/user/trades") public ResponseEntity createAuction(@RequestBody AuctionRequest dto, @RequestHeader("token") String userId ){ // 헤더에서 userId 가져오기 임시임 @@ -26,7 +25,7 @@ public ResponseEntity createAuction(@RequestBody AuctionRequest dto, } - @GetMapping + @GetMapping("/public/trades") public ResponseEntity getTrades( @RequestBody TradeFilterRequest requestDto) { @@ -36,7 +35,7 @@ public ResponseEntity getTrades( } - @PostMapping("/{auctionId}/purchase") + @PostMapping("/user/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, @RequestHeader("token") String userId) { diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 533a8f79..f47388e2 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -21,7 +21,6 @@ public enum ErrorCode { E_400_REFRESH_REQUIRED("E400008", "리프레쉬 토큰이 필요합니다.", HttpStatus.BAD_REQUEST), E_400_PASSWORD_CONFIRM_MISMATCH("E400009", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.",HttpStatus.BAD_REQUEST), E_400_PASSWORD_WHITESPACE("E400010","비밀번호에 공백을 포함할 수 없습니다.",HttpStatus.BAD_REQUEST), - E_400_INSUFFICIENT_PIA("E400011", "금액이 부족합니다.", HttpStatus.BAD_REQUEST), E_400_SELF_PURCHASE("E400012", "자기 물건은 구매할 수 없습니다.", HttpStatus.BAD_REQUEST), @@ -40,7 +39,7 @@ public enum ErrorCode { //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), - E_404_AUCTION_NOT_FOUND("E404003", "해당 경매가 존재하지 않습니다.", HttpStatus.NOT_FOUND), + E_404_AUCTION_NOT_FOUND("E404003", "해당 아이템이 존재하지 않습니다.", HttpStatus.NOT_FOUND), //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 515fc472..2d744102 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -72,7 +72,7 @@ public ItemDefResponse createItem(ItemDefRequest dto) { return toResponse(itemDef); } - // ================== DTO 변환 ================== + // DTO 변환 private ItemDefResponse toResponse(ItemDef itemDef) { ItemDefResponse response = new ItemDefResponse(); response.setId(itemDef.getId()); From 4ee2a194a7bf883e0e5e2125ebce192a6feca7d4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 04:23:10 +0900 Subject: [PATCH 084/527] =?UTF-8?q?=C3=A3feat:=20add=20custom=20exception?= =?UTF-8?q?=20to=20createAuction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/scriptopia/demo/controller/AuctionController.java | 7 ++++++- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 4 ++++ .../java/com/scriptopia/demo/service/AuctionService.java | 6 ++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index b9942f30..71d4e896 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController @@ -19,8 +20,9 @@ public class AuctionController { @PostMapping("/user/trades") public ResponseEntity createAuction(@RequestBody AuctionRequest dto, - @RequestHeader("token") String userId ){ // 헤더에서 userId 가져오기 임시임 + Authentication authentication ){ + Long userId = Long.valueOf(authentication.getName()); return ResponseEntity.ok(auctionService.createAuction(dto, userId)); } @@ -45,4 +47,7 @@ public ResponseEntity purchaseItem( } + + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index f47388e2..007a3c0e 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -24,6 +24,10 @@ public enum ErrorCode { E_400_INSUFFICIENT_PIA("E400011", "금액이 부족합니다.", HttpStatus.BAD_REQUEST), E_400_SELF_PURCHASE("E400012", "자기 물건은 구매할 수 없습니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_USER_ITEM_ID("E400013", "잘못된 아이템 ID 형식입니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_NOT_OWNED("E400014", "해당 아이템은 사용자가 소유하지 않았습니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_NOT_TRADEABLE("E400015", "해당 아이템은 현재 경매장에 올릴 수 없습니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 73ef50da..f6169c32 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -35,7 +35,9 @@ public class AuctionService { private final SettlementRepository settlementRepository; @Transactional - public String createAuction(AuctionRequest requestDto, String userId) { + public String createAuction(AuctionRequest requestDto, Long userId) { + + // UUID(String) → Long 변환 (임시) long userItemId; @@ -50,7 +52,7 @@ public String createAuction(AuctionRequest requestDto, String userId) { .orElseThrow(() -> new IllegalArgumentException("UserItem not found")); // 유저 소유 여부 확인 - if (!userItem.getUser().getId().equals(Long.parseLong(userId))) { + if (!userItem.getUser().getId().equals(userId)) { throw new IllegalStateException("해당 아이템은 사용자가 소유하지 않았습니다."); } From 916d7cb2dc7a7c9b2f8fafb0e5c53655c74ef6f4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 04:29:00 +0900 Subject: [PATCH 085/527] =?UTF-8?q?=C3=A3feat:=20add=20custom=20exception?= =?UTF-8?q?=20to=20createAuction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scriptopia/demo/service/AuctionService.java | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index f6169c32..889de5b2 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -40,31 +40,26 @@ public String createAuction(AuctionRequest requestDto, Long userId) { // UUID(String) → Long 변환 (임시) - long userItemId; - try { - userItemId = Long.parseLong(requestDto.getItemDefId()); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid UserItem UUID"); - } + long userItemId = Long.parseLong(requestDto.getItemDefId()); + // UserItem 조회 UserItem userItem = userItemRepository.findById(userItemId) - .orElseThrow(() -> new IllegalArgumentException("UserItem not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); // 유저 소유 여부 확인 if (!userItem.getUser().getId().equals(userId)) { - throw new IllegalStateException("해당 아이템은 사용자가 소유하지 않았습니다."); + throw new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED); } // 거래 상태 확인 if (userItem.getTradeStatus() != TradeStatus.OWNED) { - throw new IllegalStateException( - "해당 아이템은 현재 경매장에 올릴 수 없습니다. 상태: " + userItem.getTradeStatus()); + throw new CustomException(ErrorCode.E_400_ITEM_NOT_TRADEABLE); } // 이미 경매장에 등록되어 있는지 확인 if (auctionRepository.existsByUserItem(userItem)) { - throw new IllegalStateException("이미 경매장에 등록된 아이템입니다."); + throw new CustomException(ErrorCode.E_400_ITEM_ALREADY_REGISTERED); } // Auction 등록 From df52b3b16e61df6e9196800a4dbac4f3109c4af3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 12:25:39 +0900 Subject: [PATCH 086/527] =?UTF-8?q?=C3=A3feat:=20creat=20pia=20method=20in?= =?UTF-8?q?=20=20User=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/controller/AuctionController.java | 15 +++++- .../java/com/scriptopia/demo/domain/User.java | 15 ++++++ .../scriptopia/demo/exception/ErrorCode.java | 8 +++- .../demo/service/AuctionService.java | 47 ++++++++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 71d4e896..7a9a4e21 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -40,13 +40,24 @@ public ResponseEntity getTrades( @PostMapping("/user/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, - @RequestHeader("token") String userId) { + Authentication authentication) { + + Long userId = Long.valueOf(authentication.getName()); String result = auctionService.purchaseItem(auctionId, userId); return ResponseEntity.ok(result); - } + @PatchMapping("/user/trades/{settlementId}/confirm") + public ResponseEntity confirmItem( + @PathVariable String settlementId, + Authentication authentication) { + + + Long userId = Long.valueOf(authentication.getName()); + String result = auctionService.confirmItem(settlementId, userId); + return ResponseEntity.ok(result); + } diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 8958ba35..2c993001 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.domain; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -27,4 +29,17 @@ public class User { @Enumerated(EnumType.STRING) private Role role; + + // 거래 관련 도메인 메소드 + public void addPia(Long amount) { + if (amount <= 0) throw new CustomException(ErrorCode.E_400_INVALID_AMOUNT); + this.pia += amount; + } + + public void subtractPia(Long amount) { + if (amount <= 0) throw new CustomException(ErrorCode.E_400_INVALID_AMOUNT); + if (this.pia < amount) throw new CustomException(ErrorCode.E_400_INSUFFICIENT_PIA); + this.pia -= amount; + } + } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 007a3c0e..63748aa8 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -23,11 +23,11 @@ public enum ErrorCode { E_400_PASSWORD_WHITESPACE("E400010","비밀번호에 공백을 포함할 수 없습니다.",HttpStatus.BAD_REQUEST), E_400_INSUFFICIENT_PIA("E400011", "금액이 부족합니다.", HttpStatus.BAD_REQUEST), E_400_SELF_PURCHASE("E400012", "자기 물건은 구매할 수 없습니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_USER_ITEM_ID("E400013", "잘못된 아이템 ID 형식입니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_NOT_OWNED("E400014", "해당 아이템은 사용자가 소유하지 않았습니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_NOT_TRADEABLE("E400015", "해당 아이템은 현재 경매장에 올릴 수 없습니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized @@ -39,17 +39,23 @@ public enum ErrorCode { //403 Forbidden E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), E_403_DEVICE_MISMATCH("E403002", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), + E_403_SETTLEMENT_FORBIDDEN("E403003", "해당 정산 내역에 접근할 권한이 없습니다.", HttpStatus.FORBIDDEN), + //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_AUCTION_NOT_FOUND("E404003", "해당 아이템이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + E_404_SETTLEMENT_NOT_FOUND("E404004","정산 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), E_409_NICKNAME_TAKEN("E409002", "이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT), E_409_REFRESH_REUSE_DETECTED("E409003", "리프레시 토큰 재사용이 감지되었습니다.", HttpStatus.CONFLICT), E_409_PASSWORD_SAME_AS_OLD("E409004","기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.",HttpStatus.CONFLICT), + E_409_ALREADY_CONFIRMED("E409005","이미 정산이 완료된 항목입니다.", HttpStatus.CONFLICT), + //412 Precondition Failed E_412_EMAIL_NOT_VERIFIED("E412001", "이메일 인증이 필요합니다.",HttpStatus.PRECONDITION_FAILED), diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 889de5b2..125a177f 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -172,9 +172,8 @@ public TradeResponse getTrades(TradeFilterRequest request) { @Transactional - public String purchaseItem(String auctionIdStr, String userIdStr) { + public String purchaseItem(String auctionIdStr, Long userId) { Long auctionId = Long.parseLong(auctionIdStr); - Long userId = Long.parseLong(userIdStr); // 1. 거래소 정보 조회 Auction auction = auctionRepository.findById(auctionId) @@ -228,4 +227,48 @@ public String purchaseItem(String auctionIdStr, String userIdStr) { } + @Transactional + public String confirmItem(String settlementIdStr, Long userId) { + Long settlementId = Long.parseLong(settlementIdStr); + + Settlement settlement = settlementRepository.findById(settlementId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_SETTLEMENT_NOT_FOUND)); + + // 정산 대상 유저 확인 + if (!settlement.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.E_403_ROLE_FORBIDDEN); + } + + // 이미 정산 완료 여부 확인 + if (settlement.getSettledAt() != null) { + throw new CustomException(ErrorCode.E_409_ALREADY_CONFIRMED); + } + + if (settlement.getTradeType() == TradeType.BUY) { + // 구매자 → 기존 UserItem 소유권 이전 및 상태 변경 + User buyer = settlement.getUser(); + + UserItem userItem = userItemRepository + .findByItemDefAndTradeStatus(settlement.getItemDef(), TradeStatus.SOLD) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); + + userItem.setUser(settlement.getUser()); // 구매자로 소유권 이전 + userItem.setTradeStatus(TradeStatus.OWNED); // 거래 가능 상태로 변경 + userItemRepository.save(userItem); + + } else if (settlement.getTradeType() == TradeType.SELL) { + // 판매자 → 금액 지급 + User seller = settlement.getUser(); + seller.addPia(settlement.getPrice()); // user domain 에서 관리 + userRepository.save(seller); + } + + settlement.setSettledAt(LocalDateTime.now()); + settlementRepository.save(settlement); + + return "정산이 완료되었습니다."; + } + + + } \ No newline at end of file From d7eebbd5dbdae4e670d5a0ca3859992d0f72b759 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 12:27:29 +0900 Subject: [PATCH 087/527] feat: create findByItemDefAndTradeStatus in userItemRepository --- .../com/scriptopia/demo/repository/UserItemRepository.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java index b522072f..3b8752ca 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java @@ -1,8 +1,13 @@ package com.scriptopia.demo.repository; +import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.domain.TradeStatus; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserItem; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserItemRepository extends JpaRepository { + Optional findByItemDefAndTradeStatus(ItemDef itemDef, TradeStatus tradeStatus); } From 2c1cf5f72bf7772bb00a4d4ed1c89d972f6e59f2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 12:30:37 +0900 Subject: [PATCH 088/527] refactor: change pia-logic for purchaseItem in User domain method --- src/main/java/com/scriptopia/demo/service/AuctionService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 125a177f..84ee4574 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -195,7 +195,8 @@ public String purchaseItem(String auctionIdStr, Long userId) { } // 4. 금액 처리 - buyer.setPia(buyer.getPia() - auction.getPrice()); + buyer.subtractPia(auction.getPrice()); + userRepository.save(buyer); // 5. UserItem 상태 변경 UserItem userItem = auction.getUserItem(); From 49d4be6e9233c911b8a85663526cc8d0da4be010 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 13:45:11 +0900 Subject: [PATCH 089/527] feat: add error code E_401 --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index c0abe974..c5785313 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -23,6 +23,7 @@ public enum ErrorCode { E_400_PASSWORD_WHITESPACE("E400010","비밀번호에 공백을 포함할 수 없습니다.",HttpStatus.BAD_REQUEST), //401 Unauthorized + E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), From 7918768da2a64765e09ca4fd7e991b5cea00ed50 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 13:47:17 +0900 Subject: [PATCH 090/527] feat: add erorr code E_403 --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index c5785313..285e8a8f 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -30,8 +30,8 @@ public enum ErrorCode { E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), //403 Forbidden - E_403_ROLE_FORBIDDEN("E403001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - E_403_DEVICE_MISMATCH("E403002", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), + E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + E_403_DEVICE_MISMATCH("E403001", "요청 디바이스와 토큰의 디바이스가 일치하지 않습니다.", HttpStatus.FORBIDDEN), //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), From 20a2b8b0da7bae3b7578ead96a8ded4114493f0d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 13:49:53 +0900 Subject: [PATCH 091/527] feat: modify error handle in filterChain static error -> custom error handle --- .../com/scriptopia/demo/config/SecurityConfig.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 890f3a76..6cbb9b39 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.config; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -51,18 +53,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> { - res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - res.setContentType(MediaType.APPLICATION_JSON_VALUE); - res.getOutputStream().write( - "{\"code\":\"AUTH_401\",\"message\":\"Unauthorized\"}" - .getBytes(StandardCharsets.UTF_8)); + throw new CustomException(ErrorCode.E_401); }) .accessDeniedHandler((req, res, e) -> { - res.setStatus(HttpServletResponse.SC_FORBIDDEN); - res.setContentType(MediaType.APPLICATION_JSON_VALUE); - res.getOutputStream().write( - "{\"code\":\"AUTH_403\",\"message\":\"Forbidden\"}" - .getBytes(StandardCharsets.UTF_8)); + throw new CustomException(ErrorCode.E_403); }) ); return http.build(); From fb5120a193055b4e0f20ee23bde5188080c166f7 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 14:03:08 +0900 Subject: [PATCH 092/527] feat: commonize error return format in filter chain --- .../com/scriptopia/demo/config/SecurityConfig.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 6cbb9b39..3d6223af 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.dto.exception.ErrorResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import jakarta.servlet.http.HttpServletResponse; @@ -18,6 +20,8 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import static com.scriptopia.demo.exception.ErrorCode.E_403; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -53,10 +57,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> { - throw new CustomException(ErrorCode.E_401); + res.setStatus(ErrorCode.E_401.getStatus().value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + new ObjectMapper().writeValue(res.getOutputStream(),new ErrorResponse(ErrorCode.E_401)); }) .accessDeniedHandler((req, res, e) -> { - throw new CustomException(ErrorCode.E_403); + res.setStatus(ErrorCode.E_403.getStatus().value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + new ObjectMapper().writeValue(res.getOutputStream(),new ErrorResponse(ErrorCode.E_403)); }) ); return http.build(); From 5b91077c75c62686f798b49d11e6a2d7930ef411 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 14:52:39 +0900 Subject: [PATCH 093/527] refactor/ HistoryController authentication --- .env | 2 -- .../demo/controller/HistoryController.java | 13 +++++-------- .../demo/controller/SharedGameController.java | 1 - src/main/resources/application.yml | 6 +----- 4 files changed, 6 insertions(+), 16 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 59ea747e..00000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -SPRING_MAIL_NAME=scriptopia.kr@gmail.com -SPRING_MAIL_PASSWORD=zfleptrvjuszgijx diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index b941d4cd..8824d6b8 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -1,16 +1,11 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.history.HistoryPageResponse; -import com.scriptopia.demo.dto.history.HistoryRequest; -import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/users/games") @RequiredArgsConstructor @@ -29,8 +24,10 @@ public ResponseEntity addHistory(@PathVariable String sid, Authentication aut } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ - @PostMapping("/{id}/history/seed") - public ResponseEntity seed(@PathVariable Long id) { - return historyService.seedDummySession(id); + @PostMapping("/history/seed") + public ResponseEntity seed(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return historyService.seedDummySession(userId); } } diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 7428ca0e..c3993ba7 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 00377e1a..4d100af2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,3 @@ -server: - servlet: - context-path: /api/v1 - spring: config: import: optional:file:.env[.properties] @@ -12,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create-drop properties: hibernate: show_sql: true From 957b3e8224166f1a9bbfdc2d8a882baf0a9a80eb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 16:36:17 +0900 Subject: [PATCH 094/527] feat: create SettlementHistoryRequest create SettlementHistoryResponse create SettlementHistoryResponseItem refactor: add all dto in acution dir for @anotation --- .../demo/controller/AuctionController.java | 14 ++++++++++++- .../demo/dto/auction/AuctionItemResponse.java | 10 +++++++++ .../demo/dto/auction/AuctionRequest.java | 4 ++++ .../dto/auction/SettlementHistoryRequest.java | 14 +++++++++++++ .../auction/SettlementHistoryResponse.java | 21 +++++++++++++++++++ .../SettlementHistoryResponseItem.java | 17 +++++++++++++++ .../demo/service/AuctionService.java | 14 +++++++++---- 7 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponseItem.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 7a9a4e21..84ef9b68 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -2,11 +2,11 @@ import com.scriptopia.demo.dto.auction.AuctionRequest; +import com.scriptopia.demo.dto.auction.SettlementHistoryRequest; import com.scriptopia.demo.dto.auction.TradeResponse; import com.scriptopia.demo.dto.auction.TradeFilterRequest; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -61,4 +61,16 @@ public ResponseEntity confirmItem( + @GetMapping("/user/trades/me/history") + public ResponseEntity settlementHistory( + @RequestBody SettlementHistoryRequest requestDto, + Authentication authentication) { + + + Long userId = Long.valueOf(authentication.getName()); + String result = auctionService.settlementHistory(userId, requestDto); + return ResponseEntity.ok(result); + } + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java index 3d09245f..44c051b2 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java @@ -1,13 +1,17 @@ package com.scriptopia.demo.dto.auction; import com.scriptopia.demo.domain.TradeStatus; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.List; @Data +@NoArgsConstructor +@AllArgsConstructor public class AuctionItemResponse { private Long auctionId; @@ -18,12 +22,16 @@ public class AuctionItemResponse { private ItemDto item; @Data + @NoArgsConstructor + @AllArgsConstructor public static class UserDto { private Long userId; private String nickname; } @Data + @NoArgsConstructor + @AllArgsConstructor public static class ItemDto { private Long userItemId; private Long itemDefId; @@ -42,6 +50,8 @@ public static class ItemDto { } @Data + @NoArgsConstructor + @AllArgsConstructor public static class ItemEffectDto { private String effectName; private String effectDescription; diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java index e6788c9a..903ab142 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionRequest.java @@ -2,9 +2,13 @@ import com.scriptopia.demo.domain.TradeStatus; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@NoArgsConstructor +@AllArgsConstructor public class AuctionRequest { private String itemDefId; // 단수형으로 변경함 private Long price; diff --git a/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryRequest.java new file mode 100644 index 00000000..bde72c41 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.auction; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SettlementHistoryRequest { + private Long pageIndex; + private Long pageSize; +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponse.java new file mode 100644 index 00000000..5bda0b7f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponse.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.dto.auction; + +import lombok.*; + +import java.util.List; + + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SettlementHistoryResponse { + private List content; + private PageInfo pageInfo; + + + @Data + public static class PageInfo { + private int currentPage; + private int pageSize; + } +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponseItem.java b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponseItem.java new file mode 100644 index 00000000..57885cdd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/SettlementHistoryResponseItem.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.auction; +import lombok.*; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SettlementHistoryResponseItem { + private Long settlementId; + private String itemName; + private String itemType; + private String itemGrade; + private Long price; + private String tradeType; // BUY / SELL + private LocalDateTime settledAt; +} diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 84ee4574..a9a393d8 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -1,10 +1,7 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.auction.AuctionRequest; -import com.scriptopia.demo.dto.auction.AuctionItemResponse; -import com.scriptopia.demo.dto.auction.TradeResponse; -import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.dto.auction.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.AuctionRepository; @@ -272,4 +269,13 @@ public String confirmItem(String settlementIdStr, Long userId) { + public TradeResponse settlementHistory(Long userId, SettlementHistoryRequest requestDto) { + int page = requestDto.getPageIndex().intValue(); + int size = requestDto.getPageSize().intValue(); + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + + } + + } \ No newline at end of file From 5d55355e8ce130749b411966703d26bd0e9f487c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 16:41:26 +0900 Subject: [PATCH 095/527] feat: create get settlement-user table logic --- .../demo/controller/AuctionController.java | 9 +++---- .../demo/repository/SettlementRepository.java | 5 ++++ .../demo/service/AuctionService.java | 25 ++++++++++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 84ef9b68..c04e571c 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -1,10 +1,7 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.auction.AuctionRequest; -import com.scriptopia.demo.dto.auction.SettlementHistoryRequest; -import com.scriptopia.demo.dto.auction.TradeResponse; -import com.scriptopia.demo.dto.auction.TradeFilterRequest; +import com.scriptopia.demo.dto.auction.*; import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -62,13 +59,13 @@ public ResponseEntity confirmItem( @GetMapping("/user/trades/me/history") - public ResponseEntity settlementHistory( + public ResponseEntity settlementHistory( @RequestBody SettlementHistoryRequest requestDto, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - String result = auctionService.settlementHistory(userId, requestDto); + SettlementHistoryResponse result = auctionService.settlementHistory(userId, requestDto); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java b/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java index dceba6a0..6b52a911 100644 --- a/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SettlementRepository.java @@ -2,7 +2,12 @@ import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.domain.Settlement; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface SettlementRepository extends JpaRepository { + + // userId 기준으로 페이징 조회 + Page findByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index a9a393d8..ab80aa64 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -269,11 +269,34 @@ public String confirmItem(String settlementIdStr, Long userId) { - public TradeResponse settlementHistory(Long userId, SettlementHistoryRequest requestDto) { + public SettlementHistoryResponse settlementHistory(Long userId, SettlementHistoryRequest requestDto) { int page = requestDto.getPageIndex().intValue(); int size = requestDto.getPageSize().intValue(); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + // 1. userId 으로 Settlement 조회 + Page settlements = settlementRepository.findByUserId(userId, pageable); + + // 2. Settlement → SettlementHistoryResponseItem 변환 + List content = settlements.stream() + .map(s -> new SettlementHistoryResponseItem( + s.getId(), + s.getItemDef().getName(), + s.getItemDef().getItemType().name(), + s.getItemDef().getItemGradeDef().getGrade().name(), + s.getPrice(), + s.getTradeType().name(), + s.getSettledAt() + )) + .toList(); + + // 3. 페이지 정보 구성 + SettlementHistoryResponse.PageInfo pageInfo = new SettlementHistoryResponse.PageInfo(); + pageInfo.setCurrentPage(settlements.getNumber()); + pageInfo.setPageSize(settlements.getSize()); + + // 4. Response DTO 생성 + return new SettlementHistoryResponse(content, pageInfo); } From e5710cfcb628b048a5f9037f24e3e535a3d3d754 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 16:50:46 +0900 Subject: [PATCH 096/527] feat: postman api test complete --- src/main/java/com/scriptopia/demo/service/AuctionService.java | 1 + src/main/resources/application.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index ab80aa64..6c9cf856 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -277,6 +277,7 @@ public SettlementHistoryResponse settlementHistory(Long userId, SettlementHistor // 1. userId 으로 Settlement 조회 Page settlements = settlementRepository.findByUserId(userId, pageable); + // 2. Settlement → SettlementHistoryResponseItem 변환 List content = settlements.stream() .map(s -> new SettlementHistoryResponseItem( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a5f807a3..59fe91b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update properties: hibernate: show_sql: true From 9de7277ded7ae4d6a86dbf91829893c1a97936e8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 16:59:37 +0900 Subject: [PATCH 097/527] create mySaleItemRequest --- .../demo/controller/AuctionController.java | 13 +++++++++++++ .../demo/dto/auction/mySaleItemRequest.java | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index c04e571c..d90242f0 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -70,4 +70,17 @@ public ResponseEntity settlementHistory( } + + + @GetMapping("/user/trades/me") + public ResponseEntity mySaleItems( + @RequestBody mySaleItemRequest requestDto, + Authentication authentication) { + + + Long userId = Long.valueOf(authentication.getName()); + SettlementHistoryResponse result = auctionService.settlementHistory(userId, requestDto); + return ResponseEntity.ok(result); + } + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java new file mode 100644 index 00000000..d08c4293 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.dto.auction; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class mySaleItemRequest { + private Long pageIndex; + private Long pageSize; +} From 533b432562eb7c4beb6643e4051809ce419b2f61 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 17:07:03 +0900 Subject: [PATCH 098/527] change name MySaleItemRequest --- .../demo/controller/AuctionController.java | 6 ++-- ...temRequest.java => MySaleItemRequest.java} | 2 +- .../demo/dto/auction/MySaleItemResponse.java | 26 ++++++++++++++++ .../dto/auction/MySaleItemResponseItem.java | 30 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) rename src/main/java/com/scriptopia/demo/dto/auction/{mySaleItemRequest.java => MySaleItemRequest.java} (87%) create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponseItem.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index d90242f0..1321c568 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -73,13 +73,13 @@ public ResponseEntity settlementHistory( @GetMapping("/user/trades/me") - public ResponseEntity mySaleItems( - @RequestBody mySaleItemRequest requestDto, + public ResponseEntity mySaleItems( + @RequestBody MySaleItemRequest requestDto, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - SettlementHistoryResponse result = auctionService.settlementHistory(userId, requestDto); + SettlementHistoryResponse result = auctionService.getMySaleItems(userId, requestDto); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemRequest.java similarity index 87% rename from src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java rename to src/main/java/com/scriptopia/demo/dto/auction/MySaleItemRequest.java index d08c4293..eeda6fa7 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/mySaleItemRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemRequest.java @@ -7,7 +7,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class mySaleItemRequest { +public class MySaleItemRequest { private Long pageIndex; private Long pageSize; } diff --git a/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponse.java new file mode 100644 index 00000000..f2e0cd92 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponse.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.dto.auction; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MySaleItemResponse { + + private List content; + private PageInfo pageInfo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PageInfo { + private int currentPage; + private int pageSize; + } + +} diff --git a/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponseItem.java b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponseItem.java new file mode 100644 index 00000000..43a46212 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/auction/MySaleItemResponseItem.java @@ -0,0 +1,30 @@ +package com.scriptopia.demo.dto.auction; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MySaleItemResponseItem { + private Long auctionId; + private Long price; + private LocalDateTime createdAt; + private ItemDto item; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ItemDto { + private Long itemDefId; + private String name; + private String itemGrade; + private String itemType; + private String mainStat; + private String picSrc; + } +} From 4b4748a82e3f90df5ac80258a185305c5a5f6088 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 17:13:31 +0900 Subject: [PATCH 099/527] create getMySaleItems service create findByUserItem_User_IdAndUserItem_TradeStatus in auctionRepository --- .../demo/controller/AuctionController.java | 2 +- .../demo/repository/AuctionRepository.java | 7 ++++ .../demo/service/AuctionService.java | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 1321c568..34fcf42c 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -79,7 +79,7 @@ public ResponseEntity mySaleItems( Long userId = Long.valueOf(authentication.getName()); - SettlementHistoryResponse result = auctionService.getMySaleItems(userId, requestDto); + MySaleItemResponse result = auctionService.getMySaleItems(userId, requestDto); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index d6b33688..281633a7 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -60,5 +60,12 @@ Page findByFilters( ); + Page findByUserItem_User_IdAndUserItem_TradeStatus( + Long userId, + TradeStatus tradeStatus, + Pageable pageable + ); + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 6c9cf856..82e898c9 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -302,4 +302,42 @@ public SettlementHistoryResponse settlementHistory(Long userId, SettlementHistor } + + public MySaleItemResponse getMySaleItems(Long userId, MySaleItemRequest requestDto) { + int page = requestDto.getPageIndex().intValue(); + int size = requestDto.getPageSize().intValue(); + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + + // 현재 판매중(LISTED)인 아이템만 조회 + Page auctions = auctionRepository.findByUserItem_User_IdAndUserItem_TradeStatus( + userId, + TradeStatus.LISTED, + pageable + ); + + // 응답 content 변환 + List content = auctions.stream() + .map(auction -> new MySaleItemResponseItem( + auction.getId(), + auction.getPrice(), + auction.getCreatedAt(), + new MySaleItemResponseItem.ItemDto( + auction.getUserItem().getItemDef().getId(), + auction.getUserItem().getItemDef().getName(), + auction.getUserItem().getItemDef().getItemGradeDef().getGrade().name(), + auction.getUserItem().getItemDef().getItemType().name(), + auction.getUserItem().getItemDef().getMainStat().name(), + auction.getUserItem().getItemDef().getPicSrc() + ) + )).toList(); + + + + // 페이지 정보 + MySaleItemResponse.PageInfo pageInfo = new MySaleItemResponse.PageInfo(page, size); + return new MySaleItemResponse(content, pageInfo); + } + + } \ No newline at end of file From a36d2d580f616fc4aa4c780d97f170555dc54e24 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 18:17:17 +0900 Subject: [PATCH 100/527] completet postman api test --- .../java/com/scriptopia/demo/controller/AuctionController.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 34fcf42c..991f42b6 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -70,8 +70,6 @@ public ResponseEntity settlementHistory( } - - @GetMapping("/user/trades/me") public ResponseEntity mySaleItems( @RequestBody MySaleItemRequest requestDto, From 2f2b8673a1a0381a2f10d8c42a00361dabbae22a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 18:57:17 +0900 Subject: [PATCH 101/527] create cancelMySaleItem to controller create createAuction to service --- .../demo/controller/AuctionController.java | 13 ++++++++ .../demo/service/AuctionService.java | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 991f42b6..9700ec74 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -81,4 +81,17 @@ public ResponseEntity mySaleItems( return ResponseEntity.ok(result); } + + + @DeleteMapping("/user/trades/{auctionId}") + public ResponseEntity cancelMySaleItem( + @PathVariable String auctionId, + Authentication authentication) { + + Long userId = Long.valueOf(authentication.getName()); + String result = auctionService.cancelMySaleItem(userId, auctionId); + return ResponseEntity.ok(result); + } + + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 82e898c9..ba77d685 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -340,4 +340,35 @@ public MySaleItemResponse getMySaleItems(Long userId, MySaleItemRequest requestD } + + @Transactional + public String cancelMySaleItem(Long userId, String auctionIdStr) { + + //임시 uuid를 사용한다면 추후 변형하는 메소드로 변경바람 + Long auctionId = Long.parseLong(auctionIdStr); + + + // 1. 경매 정보 조회 + Auction auction = auctionRepository.findById(auctionId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); + + UserItem userItem = auction.getUserItem(); + + // 2. 본인 검증 + if (!userItem.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.E_403_ROLE_FORBIDDEN); + } + + + // 3. UserItem 상태 원복 + userItem.setTradeStatus(TradeStatus.OWNED); + userItemRepository.save(userItem); + + + // 4. 경매장에서 삭제 + auctionRepository.delete(auction); + + return "판매 등록이 취소되었습니다."; + } + } \ No newline at end of file From 6999f8d69951f920af05b66d2f06cb64584e51ed Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:02:51 +0900 Subject: [PATCH 102/527] feat: add E_400_MISSING_JWT --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- src/main/java/com/scriptopia/demo/service/AuctionService.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9a548f6b..602ad2c5 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -28,7 +28,7 @@ public enum ErrorCode { E_400_ITEM_NOT_TRADEABLE("E400015", "해당 아이템은 현재 경매장에 올릴 수 없습니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), - + E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index ba77d685..32970ea6 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -234,7 +234,7 @@ public String confirmItem(String settlementIdStr, Long userId) { // 정산 대상 유저 확인 if (!settlement.getUser().getId().equals(userId)) { - throw new CustomException(ErrorCode.E_403_ROLE_FORBIDDEN); + throw new CustomException(ErrorCode.E_403); } // 이미 정산 완료 여부 확인 @@ -356,7 +356,7 @@ public String cancelMySaleItem(Long userId, String auctionIdStr) { // 2. 본인 검증 if (!userItem.getUser().getId().equals(userId)) { - throw new CustomException(ErrorCode.E_403_ROLE_FORBIDDEN); + throw new CustomException(ErrorCode.E_403); } From 7e39e2a7e051ce9dd6fb2de7babf60fd2e05b16c Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:03:31 +0900 Subject: [PATCH 103/527] feat: add error code E_401_INVALID_SIGNATURE --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 602ad2c5..d29d35eb 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -36,6 +36,7 @@ public enum ErrorCode { E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), + E_401_INVALID_SIGNATURE("E401001", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), From deac4d89828aa1aa565fc4850306bb890edbcb24 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:04:01 +0900 Subject: [PATCH 104/527] feat: add error code E_401_MALFORMED --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index d29d35eb..e296b986 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -37,6 +37,7 @@ public enum ErrorCode { E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), E_401_INVALID_SIGNATURE("E401001", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_MALFORMED("E401002", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), From 5a4dc4e81dc10cbd0add96b284823db19047e669 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:04:48 +0900 Subject: [PATCH 105/527] feat: add error code E_401_EXPIRED_JWT --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index e296b986..36e08a0c 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -38,6 +38,7 @@ public enum ErrorCode { E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), E_401_INVALID_SIGNATURE("E401001", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_MALFORMED("E401002", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_EXPIRED_JWT("E401003", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), From 9151ea285a6583e8e2dad54f6faa9930e7f9f0d9 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:05:43 +0900 Subject: [PATCH 106/527] feat: add error code E_401_UNSUPPORTED_JWT --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 36e08a0c..916faef5 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -39,6 +39,7 @@ public enum ErrorCode { E_401_INVALID_SIGNATURE("E401001", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_MALFORMED("E401002", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_EXPIRED_JWT("E401003", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + E_401_UNSUPPORTED_JWT("E401004", "지원하지 않는 JWT 형식입니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), From 6965f60087a89eb807fc21cfab695ae4e681bf89 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:14:15 +0900 Subject: [PATCH 107/527] feat: apply custom JWT exception handling with global handler --- .../scriptopia/demo/config/JwtAuthFilter.java | 33 ++++++++++++------- .../demo/config/SecurityConfig.java | 3 +- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 1dd16af4..6a8c548a 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -1,6 +1,12 @@ package com.scriptopia.demo.config; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -13,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.io.IOException; +import java.security.SignatureException; import java.util.stream.Collectors; @Component @@ -31,9 +38,11 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, return; } - String authHeader = req.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new CustomException(ErrorCode.E_400_MISSING_JWT); + } + String token = authHeader.substring(7); try { jwt.parse(token); // 유효성 체크 @@ -47,16 +56,18 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception e) { - logger.error("JWT parse error", e); - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token"); - return; + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.E_401_EXPIRED_JWT); + } catch (MalformedJwtException e) { + throw new CustomException(ErrorCode.E_401_MALFORMED); + } catch (UnsupportedJwtException e) { + throw new CustomException(ErrorCode.E_401_UNSUPPORTED_JWT); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.E_400_MISSING_JWT); + } catch (JwtException e) { + throw new CustomException(ErrorCode.E_401_INVALID_SIGNATURE); } - } else { - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization header"); - return; - } - chain.doFilter(req, res); + chain.doFilter(req, res); } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 3d6223af..ee1c8e9f 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -11,6 +11,7 @@ import org.springframework.http.MediaType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -37,7 +38,7 @@ public PasswordEncoder passwordEncoder() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(req -> { var c = new CorsConfiguration(); c.setAllowedOrigins(List.of("http://localhost:3000")); // 현재는 로컬로 해놓고 나중에 바꿔야 댐 From ea37bb1f3a57b7049f26b96314726316d1a32403 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:16:13 +0900 Subject: [PATCH 108/527] fix fix typo of error code E_400_ITEM_NOT_TRADE_ABLE --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- src/main/java/com/scriptopia/demo/service/AuctionService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 916faef5..f78780f4 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -25,7 +25,7 @@ public enum ErrorCode { E_400_SELF_PURCHASE("E400012", "자기 물건은 구매할 수 없습니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_USER_ITEM_ID("E400013", "잘못된 아이템 ID 형식입니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_NOT_OWNED("E400014", "해당 아이템은 사용자가 소유하지 않았습니다.", HttpStatus.BAD_REQUEST), - E_400_ITEM_NOT_TRADEABLE("E400015", "해당 아이템은 현재 경매장에 올릴 수 없습니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_NOT_TRADE_ABLE("E400015", "해당 아이템은 현재 경매장에 올릴 수 없습니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 32970ea6..08c53951 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -51,7 +51,7 @@ public String createAuction(AuctionRequest requestDto, Long userId) { // 거래 상태 확인 if (userItem.getTradeStatus() != TradeStatus.OWNED) { - throw new CustomException(ErrorCode.E_400_ITEM_NOT_TRADEABLE); + throw new CustomException(ErrorCode.E_400_ITEM_NOT_TRADE_ABLE); } // 이미 경매장에 등록되어 있는지 확인 From 52984fb58a4b41682ab896995fb4396b99f4313f Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 30 Aug 2025 20:17:06 +0900 Subject: [PATCH 109/527] fix: fix 401 error code sequence --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index f78780f4..e92e6cd7 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -36,10 +36,10 @@ public enum ErrorCode { E_401_CODE_MISMATCH("E401002","인증 코드가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_REFRESH_EXPIRED("E401003","리프레쉬 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), E_401_CURRENT_PASSWORD_MISMATCH("E401004","현재 비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED), - E_401_INVALID_SIGNATURE("E401001", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), - E_401_MALFORMED("E401002", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), - E_401_EXPIRED_JWT("E401003", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), - E_401_UNSUPPORTED_JWT("E401004", "지원하지 않는 JWT 형식입니다.", HttpStatus.UNAUTHORIZED), + E_401_INVALID_SIGNATURE("E401005", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_MALFORMED("E401006", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), + E_401_EXPIRED_JWT("E401007", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + E_401_UNSUPPORTED_JWT("E401008", "지원하지 않는 JWT 형식입니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), From 07d47d36cf4589ae3a3b2bf9251a61c2f6e06692 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:18:44 +0900 Subject: [PATCH 110/527] create PiaShopController --- .../demo/controller/PiaShopController.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/PiaShopController.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java new file mode 100644 index 00000000..c295fb87 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.controller; + + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class PiaShopController { + + + + + @PostMapping("/admin/items/pia") + +} From e820c89e0c59fbc6dc0bf27d72815819983aa677 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:23:22 +0900 Subject: [PATCH 111/527] create createPiaItem to controller --- .../demo/controller/PiaShopController.java | 13 +++++++++---- .../demo/dto/piashop/PiaItemRequest.java | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/piashop/PiaItemRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index c295fb87..de315d6e 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,17 +1,22 @@ package com.scriptopia.demo.controller; - +import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.repository.PiaItemRepository; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor +@RequestMapping("/admin/items") public class PiaShopController { + @PostMapping("/pia") + public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { - @PostMapping("/admin/items/pia") + return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemRequest.java b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemRequest.java new file mode 100644 index 00000000..b5f4b8e1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemRequest.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.piashop; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PiaItemRequest { + private String name; + private Long price; + private String description; +} From 4823d799853cd7d6bb3ec79c7bfb70f7a904fe2e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:29:34 +0900 Subject: [PATCH 112/527] create PiaShopRepository to repository --- .../demo/controller/PiaShopController.java | 3 +-- .../demo/repository/PiaShopRepository.java | 9 +++++++++ .../scriptopia/demo/service/PiaShopService.java | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java create mode 100644 src/main/java/com/scriptopia/demo/service/PiaShopService.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index de315d6e..0f3a2c18 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,7 +1,6 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.piashop.PiaItemRequest; -import com.scriptopia.demo.repository.PiaItemRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -10,7 +9,7 @@ @RequiredArgsConstructor @RequestMapping("/admin/items") public class PiaShopController { - + private final P @PostMapping("/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { diff --git a/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java new file mode 100644 index 00000000..ccfb9f76 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java @@ -0,0 +1,9 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.PiaItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PiaShopRepository extends JpaRepository { + +} + diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java new file mode 100644 index 00000000..83aa6be3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PiaShopService { + + +} From 1d6f7481850b0be1f2e99ca46424fd6d9bdc0b31 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:30:33 +0900 Subject: [PATCH 113/527] delete PiaShopRepository --- .../scriptopia/demo/repository/PiaShopRepository.java | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java deleted file mode 100644 index ccfb9f76..00000000 --- a/src/main/java/com/scriptopia/demo/repository/PiaShopRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.scriptopia.demo.repository; - -import com.scriptopia.demo.domain.PiaItem; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PiaShopRepository extends JpaRepository { - -} - From 63c657bc1c40306d6dda5be716a7a52d4d0e68fb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:42:37 +0900 Subject: [PATCH 114/527] create createPiaItem to service --- .../com/scriptopia/demo/controller/PiaShopController.java | 8 ++++---- .../java/com/scriptopia/demo/service/PiaShopService.java | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 0f3a2c18..26af36c7 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,21 +1,21 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor -@RequestMapping("/admin/items") public class PiaShopController { - private final P + private final PiaShopService piaShopService; - @PostMapping("/pia") + @PostMapping("/admin/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { - + piaShopService.createPiaItem(request); return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); } } diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index 83aa6be3..091b2c61 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.dto.piashop.PiaItemRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,4 +12,11 @@ public class PiaShopService { + @Transactional + public String createPiaItem(PiaItemRequest request){ + + + + } + } From 861b6b27b06d0a25b8242c7a61254fdc7732f124 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:44:16 +0900 Subject: [PATCH 115/527] create existsByName to PiaItemRepository --- .../demo/repository/PiaItemRepository.java | 1 + .../demo/service/PiaShopService.java | 35 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java index fb05f2c6..56fecc17 100644 --- a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface PiaItemRepository extends JpaRepository { + boolean existsByName(String name); // 이름으로 중복 체크 } diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index 091b2c61..a3ae2f11 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -1,7 +1,10 @@ package com.scriptopia.demo.service; - +import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.PiaItemRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,12 +14,32 @@ @Transactional(readOnly = true) public class PiaShopService { + private final PiaItemRepository piaItemRepository; @Transactional - public String createPiaItem(PiaItemRequest request){ - - - + public String createPiaItem(PiaItemRequest request) { + + // 1. 필수 값 확인 + if(request.getName() == null || request.getName().isBlank()) { + throw new CustomException(ErrorCode.E_400_MISSING_NICKNAME); // 이름 필수 체크 + } + if(request.getPrice() == null || request.getPrice() <= 0) { + throw new CustomException(ErrorCode.E_400_INVALID_AMOUNT); + } + + // 2. 중복 이름 체크 + if(piaItemRepository.existsByName(request.getName())) { + throw new CustomException(ErrorCode.E_409_ALREADY_CONFIRMED); // 적절한 오류 코드 선택 + } + + // 3. PiaItem 생성 + PiaItem piaItem = new PiaItem(); + piaItem.setName(request.getName()); + piaItem.setPrice(String.valueOf(request.getPrice())); + piaItem.setDescription(request.getDescription()); + + piaItemRepository.save(piaItem); + + return "PIA 아이템이 성공적으로 생성되었습니다."; } - } From c76af1cae9b6ddde1029d1671a5569b803168796 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:49:01 +0900 Subject: [PATCH 116/527] create E_400_PIA_ITEM_DUPLICATE create E_400_INVALID_REQUEST both to custom exception --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index e92e6cd7..054f21f0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -29,6 +29,9 @@ public enum ErrorCode { E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), + E_400_PIA_ITEM_DUPLICATE("E400100", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_REQUEST("E400101", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), + //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), From 294e8058e80d905eca853e4c1a32b041a6dde339 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 20:54:25 +0900 Subject: [PATCH 117/527] feature/82-shared-game-favorite-save, service, controller, gameTag --- .../SharedGameFavoriteController.java | 24 +++++++ .../com/scriptopia/demo/domain/GameTag.java | 3 + .../SharedGameFavoriteResponse.java | 15 +++++ .../scriptopia/demo/exception/ErrorCode.java | 1 + .../demo/repository/GameTagRepository.java | 8 +++ .../SharedGameFavoriteRepository.java | 6 ++ .../repository/SharedGameScoreRepository.java | 6 ++ .../service/SharedGameFavoriteService.java | 64 +++++++++++++++++++ src/main/resources/application.yml | 2 +- 9 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java create mode 100644 src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java new file mode 100644 index 00000000..05735575 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java @@ -0,0 +1,24 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.service.SharedGameFavoriteService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/users/games") +@RequiredArgsConstructor +public class SharedGameFavoriteController { + private final SharedGameFavoriteService sharedGameFavoriteService; + + @PostMapping("/shared/{sharedGameId}/like") + public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); + } +} diff --git a/src/main/java/com/scriptopia/demo/domain/GameTag.java b/src/main/java/com/scriptopia/demo/domain/GameTag.java index d06c935d..6501657e 100644 --- a/src/main/java/com/scriptopia/demo/domain/GameTag.java +++ b/src/main/java/com/scriptopia/demo/domain/GameTag.java @@ -14,6 +14,9 @@ public class GameTag { @GeneratedValue private Long id; + @ManyToOne(fetch = FetchType.LAZY) + private SharedGame sharedGame; + @ManyToOne(fetch = FetchType.LAZY) private TagDef tagDef; } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java new file mode 100644 index 00000000..ed40383f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.sharedgamefavorite; + +import lombok.Data; + +@Data +public class SharedGameFavoriteResponse { + private Long sharedGameId; + private String thumbnailUrl; + private boolean isLiked; + private Long likeCount; + private Long totalPlayCount; + private String title; + private String[] tags; + private Float topScore; +} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index c0abe974..4c1f8ff8 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -35,6 +35,7 @@ public enum ErrorCode { //404 Not Found E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), + E_404_SHARED_GAME_NOT_FOUND("E404005", "공유된 게임을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), diff --git a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java index a2f966cc..1b8cda84 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java @@ -2,6 +2,14 @@ import com.scriptopia.demo.domain.GameTag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface GameTagRepository extends JpaRepository { + @Query("select gt.tagDef.tagName " + + "from GameTag gt " + + "where gt.sharedGame.id = :sharedGameId") + List findTagNamesBySharedGameId(@Param("sharedGameId") Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java index c5fbe2f0..9c6d0301 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java @@ -4,5 +4,11 @@ import com.scriptopia.demo.domain.SharedGameFavorite; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface SharedGameFavoriteRepository extends JpaRepository { + Optional findByUserIdAndSharedGameId(Long userId, Long sharedGameId); + boolean existsByUserIdAndSharedGameId(Long userId, Long sharedGameId); + long countBySharedGameId(Long sharedGameId); + void deleteByUserIdAndSharedGameId(Long userId, Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java index d4adc80f..565fd962 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java @@ -3,6 +3,12 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameScore; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface SharedGameScoreRepository extends JpaRepository { + @Query("Select count(s) from SharedGameScore s where s.sharedGame.id = :sharedGameId") + long countBySharedGameId(Long sharedGameId); + + @Query("select max(s.score) from SharedGameScore s where s.sharedGame.id = :sharedGameId") + Long maxScoreBySharedGameId(Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java new file mode 100644 index 00000000..fccacef1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java @@ -0,0 +1,64 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.SharedGameFavorite; +import com.scriptopia.demo.dto.sharedgamefavorite.SharedGameFavoriteResponse; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class SharedGameFavoriteService { + private final SharedGameFavoriteRepository sharedGameFavoriteRepository; + private final SharedGameRepository sharedGameRepository; + private final GameTagRepository gameTagRepository; + private final UserRepository userRepository; + private final SharedGameScoreRepository sharedGameScoreRepository; + + @Transactional + public ResponseEntity saveFavorite(Long userId, Long sharedGameId) { + var user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + var game = sharedGameRepository.findById(sharedGameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); + + // 토글 처리 + var existing = sharedGameFavoriteRepository.findByUserIdAndSharedGameId(userId, sharedGameId); + boolean liked; + if (existing.isPresent()) { + sharedGameFavoriteRepository.delete(existing.get()); + liked = false; + } else { + var fav = new SharedGameFavorite(); + fav.setUser(user); + fav.setSharedGame(game); + sharedGameFavoriteRepository.save(fav); + liked = true; + } + + long likeCount = sharedGameFavoriteRepository.countBySharedGameId(sharedGameId); + long playCount = sharedGameScoreRepository.countBySharedGameId(sharedGameId); + Long maxScore = sharedGameScoreRepository.maxScoreBySharedGameId(sharedGameId); + + // 태그 이름들 + var tagNames = gameTagRepository.findTagNamesBySharedGameId(sharedGameId); + + // DTO 구성 + var dto = new SharedGameFavoriteResponse(); + dto.setSharedGameId(sharedGameId); + dto.setThumbnailUrl(game.getThumbnailUrl()); + dto.setLiked(liked); + dto.setLikeCount(likeCount); + dto.setTotalPlayCount(playCount); + dto.setTitle(game.getTitle()); + dto.setTags(tagNames.isEmpty() ? null : tagNames.toArray(new String[0])); + dto.setTopScore(maxScore == null ? null : maxScore.floatValue()); + + return ResponseEntity.ok(dto); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a5f807a3..59fe91b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update properties: hibernate: show_sql: true From 11acebd7f617e77b6dbd7cb1ff2ba428c14c62ca Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 20:55:39 +0900 Subject: [PATCH 118/527] create createPiaItem service method --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- .../java/com/scriptopia/demo/service/PiaShopService.java | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 054f21f0..1b252c8d 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -30,7 +30,7 @@ public enum ErrorCode { E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_PIA_ITEM_DUPLICATE("E400100", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_REQUEST("E400101", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_REQUEST("E400101", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index a3ae2f11..66ad76dd 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -21,17 +21,19 @@ public String createPiaItem(PiaItemRequest request) { // 1. 필수 값 확인 if(request.getName() == null || request.getName().isBlank()) { - throw new CustomException(ErrorCode.E_400_MISSING_NICKNAME); // 이름 필수 체크 + throw new CustomException(ErrorCode.E_400_INVALID_REQUEST); // 이름 필수 체크 } + if(request.getPrice() == null || request.getPrice() <= 0) { - throw new CustomException(ErrorCode.E_400_INVALID_AMOUNT); + throw new CustomException(ErrorCode.E_400_INVALID_REQUEST); // 금액 유효성 체크 } // 2. 중복 이름 체크 if(piaItemRepository.existsByName(request.getName())) { - throw new CustomException(ErrorCode.E_409_ALREADY_CONFIRMED); // 적절한 오류 코드 선택 + throw new CustomException(ErrorCode.E_400_PIA_ITEM_DUPLICATE); // 중복 이름 오류 } + // 3. PiaItem 생성 PiaItem piaItem = new PiaItem(); piaItem.setName(request.getName()); From f9cbf5c4e00db32b02e2d7e575a68bd2ead722d8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 21:07:29 +0900 Subject: [PATCH 119/527] complete postman api test controller request mapping test public must be change admin for later --- .../java/com/scriptopia/demo/controller/PiaShopController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 26af36c7..51e7192c 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -11,7 +11,8 @@ public class PiaShopController { private final PiaShopService piaShopService; - @PostMapping("/admin/items/pia") + // admin → public 으로 테스트용 변경했음 나중에 수정 바람 + @PostMapping("/public/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { From 0bcb2a2c6def6704f7586382f3937836b497b01e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 21:18:54 +0900 Subject: [PATCH 120/527] create PiaItemUpdateRequest to dto --- .../demo/controller/PiaShopController.java | 4 ++-- .../demo/dto/piashop/PiaItemUpdateRequest.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/piashop/PiaItemUpdateRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 51e7192c..2ab7f467 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -14,9 +14,9 @@ public class PiaShopController { // admin → public 으로 테스트용 변경했음 나중에 수정 바람 @PostMapping("/public/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { - - piaShopService.createPiaItem(request); return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); } + + } diff --git a/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemUpdateRequest.java b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemUpdateRequest.java new file mode 100644 index 00000000..e2794ac2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemUpdateRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.piashop; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PiaItemUpdateRequest { + private String name; // 이름 + private Long price; // 가격 + private String description; // 설명 +} From 9aa0b54642054684300b0dc21429d70311dfa873 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 21:21:16 +0900 Subject: [PATCH 121/527] create updatePiaItem to controller --- .../scriptopia/demo/controller/PiaShopController.java | 11 +++++++++++ .../com/scriptopia/demo/service/PiaShopService.java | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 2ab7f467..532e6e4f 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -19,4 +20,14 @@ public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) } + @PutMapping("/public/items/pia/{itemId}") + public ResponseEntity updatePiaItem( + @PathVariable String itemId, + @RequestBody PiaItemUpdateRequest requestDto) { + + + String result = piaShopService.updatePiaItem(requestDto); + return ResponseEntity.ok(result); + } + } diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index 66ad76dd..acaa5b02 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -44,4 +44,7 @@ public String createPiaItem(PiaItemRequest request) { return "PIA 아이템이 성공적으로 생성되었습니다."; } + + + } From a665818cca811ee18d8d27c67debcfcc717647af Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 21:29:55 +0900 Subject: [PATCH 122/527] create updatePiaItem to service --- .../demo/controller/PiaShopController.java | 2 +- .../com/scriptopia/demo/domain/PiaItem.java | 2 +- .../demo/repository/PiaItemRepository.java | 2 ++ .../demo/service/PiaShopService.java | 36 ++++++++++++++++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 532e6e4f..a49d16ee 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -26,7 +26,7 @@ public ResponseEntity updatePiaItem( @RequestBody PiaItemUpdateRequest requestDto) { - String result = piaShopService.updatePiaItem(requestDto); + String result = piaShopService.updatePiaItem(itemId, requestDto); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/scriptopia/demo/domain/PiaItem.java b/src/main/java/com/scriptopia/demo/domain/PiaItem.java index 12377873..061ff177 100644 --- a/src/main/java/com/scriptopia/demo/domain/PiaItem.java +++ b/src/main/java/com/scriptopia/demo/domain/PiaItem.java @@ -15,6 +15,6 @@ public class PiaItem { private Long id; private String name; - private String price; + private Long price; private String description; } diff --git a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java index 56fecc17..4e60fda4 100644 --- a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java @@ -6,4 +6,6 @@ public interface PiaItemRepository extends JpaRepository { boolean existsByName(String name); // 이름으로 중복 체크 + + boolean existsByNameAndIdNot(String name, Long id); } diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index acaa5b02..ed1ecaf8 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.PiaItemRepository; @@ -37,7 +38,7 @@ public String createPiaItem(PiaItemRequest request) { // 3. PiaItem 생성 PiaItem piaItem = new PiaItem(); piaItem.setName(request.getName()); - piaItem.setPrice(String.valueOf(request.getPrice())); + piaItem.setPrice(request.getPrice()); piaItem.setDescription(request.getDescription()); piaItemRepository.save(piaItem); @@ -46,5 +47,38 @@ public String createPiaItem(PiaItemRequest request) { } + @Transactional + public String updatePiaItem(String itemsIdStr, PiaItemUpdateRequest request) { + + // uuid 사용 시 변경 (임시임) + Long itemsId = Long.valueOf(itemsIdStr); + + + // 1. 필수 값 체크 + if (request.getName() == null || request.getName().isBlank()) { + throw new CustomException(ErrorCode.E_400_MISSING_NICKNAME); // 이름 필수 + } + if (request.getPrice() == null || request.getPrice() <= 0) { + throw new CustomException(ErrorCode.E_400_INVALID_AMOUNT); // 가격 필수 + } + + // 2. 아이템 존재 확인 + PiaItem piaItem = piaItemRepository.findById(itemsId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); // 존재하지 않는 아이템 + + // 3. 이름 중복 체크 (자기 자신 제외) + if (piaItemRepository.existsByNameAndIdNot(request.getName(),itemsId)){ + throw new CustomException(ErrorCode.E_409_ALREADY_CONFIRMED); // 이름 중복 + } + + // 4. 정보 업데이트 + piaItem.setName(request.getName()); + piaItem.setPrice(request.getPrice()); + piaItem.setDescription(request.getDescription()); + + piaItemRepository.save(piaItem); + + return "PIA 아이템이 성공적으로 수정되었습니다."; + } } From 3e86e791e0534c0140739e07034582ee53033460 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 22:02:12 +0900 Subject: [PATCH 123/527] before pull request --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 5 +++-- .../java/com/scriptopia/demo/service/PiaShopService.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index ca3501ef..9c7357f7 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -29,8 +29,9 @@ public enum ErrorCode { E_400_ITEM_ALREADY_REGISTERED("E400016", "이미 경매장에 등록된 아이템입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_AMOUNT("E400017", "금액은 0보다 커야 합니다.", HttpStatus.BAD_REQUEST), E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), - E_400_PIA_ITEM_DUPLICATE("E400100", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_REQUEST("E400101", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), + E_400_PIA_ITEM_DUPLICATE("E400019", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_REQUEST("E400020", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), + //401 Unauthorized diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index ed1ecaf8..6334b582 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.PiaItemRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +35,6 @@ public String createPiaItem(PiaItemRequest request) { throw new CustomException(ErrorCode.E_400_PIA_ITEM_DUPLICATE); // 중복 이름 오류 } - // 3. PiaItem 생성 PiaItem piaItem = new PiaItem(); piaItem.setName(request.getName()); From eeb99e42b558bdd48abbe4d8582b7ec0bda08947 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 22:34:00 +0900 Subject: [PATCH 124/527] create PiaItemResponse to dto --- .../demo/dto/piashop/PiaItemResponse.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/piashop/PiaItemResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemResponse.java b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemResponse.java new file mode 100644 index 00000000..3c580070 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/piashop/PiaItemResponse.java @@ -0,0 +1,27 @@ +package com.scriptopia.demo.dto.piashop; + +import com.scriptopia.demo.domain.PiaItem; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PiaItemResponse { + private Long id; + private String name; + private Long price; + private String description; + + + public static PiaItemResponse fromEntity(PiaItem item) { + return new PiaItemResponse( + item.getId(), + item.getName(), + item.getPrice(), + item.getDescription() + ); + } + +} From 362791d50b5ef4b7bcf7b15b6422306312a4aeeb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 22:35:20 +0900 Subject: [PATCH 125/527] create getPiaItems to service --- .../com/scriptopia/demo/service/PiaShopService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index 6334b582..f7e3a8e4 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; @@ -11,6 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -81,4 +85,13 @@ public String updatePiaItem(String itemsIdStr, PiaItemUpdateRequest request) { return "PIA 아이템이 성공적으로 수정되었습니다."; } + + + public List getPiaItems() { + return piaItemRepository.findAll().stream() + .map(PiaItemResponse::fromEntity) + .collect(Collectors.toList()); + } + + } From 73cf6b1763895b43cc8d62eef3d1d156dd759b0e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 22:41:46 +0900 Subject: [PATCH 126/527] create getPiaItems to cotroller --- .../scriptopia/demo/controller/PiaShopController.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index a49d16ee..30d4a02c 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,12 +1,15 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.piashop.PiaItemRequest; +import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequiredArgsConstructor public class PiaShopController { @@ -20,6 +23,7 @@ public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) } + // admin → public 으로 테스트용 변경했음 나중에 수정 바람 @PutMapping("/public/items/pia/{itemId}") public ResponseEntity updatePiaItem( @PathVariable String itemId, @@ -30,4 +34,11 @@ public ResponseEntity updatePiaItem( return ResponseEntity.ok(result); } + + + @GetMapping("/public/shops/pia/items") + public ResponseEntity> getPiaItems() { + return ResponseEntity.ok(piaShopService.getPiaItems()); + } + } From a96fe1796054e602ffa8815f568f0c62c25a201a Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 23:03:36 +0900 Subject: [PATCH 127/527] refactor/ GameSessionController authentication, ErrorCode add --- .../controller/GameSessionController.java | 29 ++++++------ .../dto/gamesession/GameSessionResponse.java | 1 + .../scriptopia/demo/exception/ErrorCode.java | 1 + .../repository/GameSessionRepository.java | 7 +++ .../demo/service/GameSessionService.java | 46 ++++++++++++------- 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index d2d635d0..0cda05f5 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -5,36 +5,35 @@ import com.scriptopia.demo.service.GameSessionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/game-session") +@RequestMapping("/users/games") @RequiredArgsConstructor public class GameSessionController { private final GameSessionService gameSessionService; - @PostMapping - public ResponseEntity createGameSession(@RequestHeader("X-User-ID") Long id) { + @PostMapping("/{sessionId}/exit") + public ResponseEntity createGameSession(Authentication authentication, @PathVariable String sessionId) { // 게임 세션 정보 저장 - return gameSessionService.saveGameSession(id); + Long userId = Long.valueOf(authentication.getName()); + + return gameSessionService.saveGameSession(userId, sessionId); } // 정보 불러오기 @GetMapping - public ResponseEntity loadGameSession(@RequestHeader("X-User-ID") Long id) { - return gameSessionService.getGameSession(id); + public ResponseEntity loadGameSession(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return gameSessionService.getGameSession(userId); } - // 수정 - @PutMapping - public ResponseEntity updateGameSession(@RequestHeader("X-User-ID") Long id) { - return gameSessionService.updateGameSession(id); - } + @DeleteMapping("/{sessionId}") + public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { + Long userId = Long.valueOf(authentication.getName()); - // 삭제 - @DeleteMapping - public void deleteGameSession(@RequestHeader("X-User-ID") Long id) { - gameSessionService.deleteGameSession(id); + return gameSessionService.deleteGameSession(userId, sessionId); } } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java index 887dfd32..6245b7d5 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java @@ -8,5 +8,6 @@ @NoArgsConstructor @AllArgsConstructor public class GameSessionResponse { + private Long id; private String sessionId; } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 9c7357f7..cdb3d841 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -57,6 +57,7 @@ public enum ErrorCode { E_404_AUCTION_NOT_FOUND("E404003", "해당 아이템이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_SETTLEMENT_NOT_FOUND("E404004","정산 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), E_404_SHARED_GAME_NOT_FOUND("E404005", "공유된 게임을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), //409 Conflict diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index 5816908d..8bc317ea 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -3,6 +3,13 @@ import com.scriptopia.demo.domain.GameSession; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + public interface GameSessionRepository extends JpaRepository { + Optional findBySessionId(String sessionId); + + Optional findByMongoId(String mongoId); + List findAllByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2b6ab5c0..c56f07e9 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -4,6 +4,8 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.dto.gamesession.GameSessionRequest; import com.scriptopia.demo.dto.gamesession.GameSessionResponse; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -15,33 +17,43 @@ @RequiredArgsConstructor public class GameSessionService { private final GameSessionRepository gameSessionRepository; - // TODO User Service 리포 가져오기 - // TODO 사용자 인증 부분 필요 + private final UserRepository userRepository; - public ResponseEntity getGameSession(Long id) { - // TODO 토큰을 통한 사용자 인증 구현 - GameSessionResponse gameSessionResponse = new GameSessionResponse(); - return ResponseEntity.ok(gameSessionResponse); - } + public ResponseEntity getGameSession(Long userid) { + User user = userRepository.findById(userid) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - @Transactional - public ResponseEntity saveGameSession(Long id) { - // TODO 토큰을 통한 사용자 인증 - GameSession gameSession = new GameSession(); - return ResponseEntity.ok(gameSessionRepository.save(gameSession)); + var sessions = gameSessionRepository.findAllByUserId(user); + var dtos = sessions.stream().map(s -> { + var dto = new GameSessionResponse(); + dto.setId(s.getId()); + dto.setSessionId(s.getMongoId()); + return dto; + }).toList(); + + return ResponseEntity.ok(dtos); } @Transactional - public ResponseEntity updateGameSession(Long id) { - // TODO 토큰을 통한 사용자 인증 + public ResponseEntity saveGameSession(Long userId, String sessionId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + GameSession gameSession = new GameSession(); + gameSession.setUser(user); + gameSession.setMongoId(sessionId); return ResponseEntity.ok(gameSessionRepository.save(gameSession)); } @Transactional - public void deleteGameSession(Long id) { - // TODO 토큰을 통한 사용자 인증 - GameSession gameSession = new GameSession(); + public ResponseEntity deleteGameSession(Long userId, String sessionId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + GameSession gameSession = gameSessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); gameSessionRepository.delete(gameSession); + + return ResponseEntity.ok("선택하신 게임이 삭제되었습니다."); } } From 66ec40066924c6afb2eab8e539b4111df5b4efc4 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 23:05:06 +0900 Subject: [PATCH 128/527] modify GameSessionService --- .../java/com/scriptopia/demo/service/GameSessionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index c56f07e9..d26649d3 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -23,7 +23,7 @@ public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - var sessions = gameSessionRepository.findAllByUserId(user); + var sessions = gameSessionRepository.findAllByUserId(user.getId()); var dtos = sessions.stream().map(s -> { var dto = new GameSessionResponse(); dto.setId(s.getId()); From d00276a040c7fed1a9df0c0657ffa7157d35fa62 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 23:19:01 +0900 Subject: [PATCH 129/527] create PiaItemPurchaseLog to domain --- .../demo/controller/PiaShopController.java | 3 +++ .../demo/domain/PiaItemPurchaseLog.java | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 30d4a02c..51e7ea4a 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -41,4 +41,7 @@ public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); } + + + } diff --git a/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java b/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java new file mode 100644 index 00000000..6a670b8d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.domain; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +public class PiaItemPurchaseLog { + @Id + @GeneratedValue + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + private PiaItem piaItem; + + private LocalDateTime purchaseDate; + private Long price; +} From d7b26e2ebf2c7e6854c55b360388f195a061278c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 23:23:15 +0900 Subject: [PATCH 130/527] create PiaItemPurchaseLog to domain --- .../java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java b/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java index 6a670b8d..b21ea875 100644 --- a/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java +++ b/src/main/java/com/scriptopia/demo/domain/PiaItemPurchaseLog.java @@ -23,4 +23,10 @@ public class PiaItemPurchaseLog { private LocalDateTime purchaseDate; private Long price; + + @PrePersist + public void prePersist() { + this.purchaseDate = LocalDateTime.now(); + } + } From 2ff0db80efb4a6f161aea4bc5b767c2f2f548721 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 23:36:58 +0900 Subject: [PATCH 131/527] create purchasePiaItem to controller --- .../demo/controller/PiaShopController.java | 13 ++++- .../dto/piashop/PurchasePiaItemRequest.java | 14 +++++ .../demo/service/PiaShopService.java | 58 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/piashop/PurchasePiaItemRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 51e7ea4a..0e24abc6 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -3,9 +3,11 @@ import com.scriptopia.demo.dto.piashop.PiaItemRequest; import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; +import com.scriptopia.demo.dto.piashop.PurchasePiaItemRequest; import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -36,12 +38,21 @@ public ResponseEntity updatePiaItem( - @GetMapping("/public/shops/pia/items") + @GetMapping("/user/shops/pia/items") public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); } + @PostMapping("/user/shops/pia/orders") + public ResponseEntity purchasePiaItem( + @RequestBody PurchasePiaItemRequest requestDto, + Authentication authentication) { + + Long userId = Long.valueOf(authentication.getName()); + piaShopService.purchasePiaItem(userId, requestDto); + return ResponseEntity.ok("PIA 아이템을 구매했습니다."); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/piashop/PurchasePiaItemRequest.java b/src/main/java/com/scriptopia/demo/dto/piashop/PurchasePiaItemRequest.java new file mode 100644 index 00000000..c1fcec13 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/piashop/PurchasePiaItemRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.piashop; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PurchasePiaItemRequest { + private String itemId; + private int quantity; +} diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index f7e3a8e4..80df11ed 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -1,12 +1,18 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.PiaItem; +import com.scriptopia.demo.domain.PiaItemPurchaseLog; +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserPiaItem; import com.scriptopia.demo.dto.piashop.PiaItemRequest; import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; +import com.scriptopia.demo.dto.piashop.PurchasePiaItemRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.PiaItemRepository; +import com.scriptopia.demo.repository.UserPiaItemRepository; +import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -21,6 +27,8 @@ public class PiaShopService { private final PiaItemRepository piaItemRepository; + private final UserRepository userRepository; + private final UserPiaItemRepository userPiaItemRepository; @Transactional public String createPiaItem(PiaItemRequest request) { @@ -94,4 +102,54 @@ public List getPiaItems() { } + public void purchasePiaItem(Long userId, PurchasePiaItemRequest request) { + + + // 1. 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + // 2. 아이템 조회 + Long piaItemId = Long.parseLong(request.getItemId()); + PiaItem piaItem = piaItemRepository.findById(piaItemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); + + // 3. 총 구매 금액 계산 + long totalPrice = piaItem.getPrice() * request.getQuantity(); + if (user.getPia() < totalPrice) { + throw new CustomException(ErrorCode.E_400_INSUFFICIENT_PIA); + } + + // 4. UserPiaItem 조회 및 수량 업데이트 + UserPiaItem userPiaItem = userPiaItemRepository.findByUserAndPiaItem(user, piaItem) + .orElseGet(() -> { + UserPiaItem newItem = new UserPiaItem(); + newItem.setUser(user); + newItem.setPiaItem(piaItem); + newItem.setQuantity(0L); + return newItem; + }); + + userPiaItem.setQuantity(userPiaItem.getQuantity() + request.getQuantity()); + userPiaItemRepository.save(userPiaItem); + + // 5. 유저 금액 차감 + user.setPia(user.getPia() - totalPrice); + userRepository.save(user); + + // 6. 구매 로그 기록 + PiaItemPurchaseLog log = new PiaItemPurchaseLog(); + log.setUser(user); + log.setPiaItem(piaItem); + log.setPrice(totalPrice); + purchaseLogRepository.save(log); + } + + + + + + + + } From 418937703b8b0335c6e4450fe6f7af175510ec18 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 23:44:10 +0900 Subject: [PATCH 132/527] create purchasePiaItem to service --- .../demo/repository/PurchaseLogRepository.java | 7 +++++++ .../demo/repository/UserPiaItemRepository.java | 4 ++++ .../com/scriptopia/demo/service/PiaShopService.java | 12 +++++------- 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/repository/PurchaseLogRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/PurchaseLogRepository.java b/src/main/java/com/scriptopia/demo/repository/PurchaseLogRepository.java new file mode 100644 index 00000000..172eb88f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/PurchaseLogRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.PiaItemPurchaseLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PurchaseLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java index e6f865b1..e6061fba 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java @@ -1,8 +1,12 @@ package com.scriptopia.demo.repository; +import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserPiaItem; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserPiaItemRepository extends JpaRepository { + Optional findByUserAndPiaItem(User user, PiaItem piaItem); } diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index 80df11ed..d206e8dc 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -11,6 +11,7 @@ import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.PiaItemRepository; +import com.scriptopia.demo.repository.PurchaseLogRepository; import com.scriptopia.demo.repository.UserPiaItemRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -29,6 +30,7 @@ public class PiaShopService { private final PiaItemRepository piaItemRepository; private final UserRepository userRepository; private final UserPiaItemRepository userPiaItemRepository; + private final PurchaseLogRepository purchaseLogRepository; @Transactional public String createPiaItem(PiaItemRequest request) { @@ -102,15 +104,17 @@ public List getPiaItems() { } + @Transactional public void purchasePiaItem(Long userId, PurchasePiaItemRequest request) { + // uuid 로 사용할 것이기 때문에 임시임 + Long piaItemId = Long.valueOf(request.getItemId()); // 1. 유저 조회 User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); // 2. 아이템 조회 - Long piaItemId = Long.parseLong(request.getItemId()); PiaItem piaItem = piaItemRepository.findById(piaItemId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_AUCTION_NOT_FOUND)); @@ -146,10 +150,4 @@ public void purchasePiaItem(Long userId, PurchasePiaItemRequest request) { } - - - - - - } From c9730dcc91eee1392f973a698c972fc65d6bacf9 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 23:46:09 +0900 Subject: [PATCH 133/527] exception handler historyservice add --- .../java/com/scriptopia/demo/service/HistoryService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 0ff0b3da..a9c5113a 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -6,6 +6,8 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.history.HistoryRequest; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.HistoryRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -50,7 +52,8 @@ public ResponseEntity createHistory(Long userId, String sid) { } HistoryRequest req = mapMongoToHistoryRequest(doc); - User user = userRepository.findById(userId).orElseThrow(); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); History history = new History(user, req); return ResponseEntity.ok(historyRepository.save(history)); From cb00ea961ef19d5f32732dbefc3e1064b2f03131 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 30 Aug 2025 23:48:40 +0900 Subject: [PATCH 134/527] exception handler tag controller --- .../demo/controller/TagDefController.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index 2ece02c7..c97745cc 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -5,20 +5,25 @@ import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -@RestController("/tags") +@RestController("/admin/tag") @RequiredArgsConstructor public class TagDefController { private final TagDefService tagDefService; @PostMapping - public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, @RequestHeader("X-USER-ID")Long id) { - return tagDefService.addTagName(req, id); + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return tagDefService.addTagName(req, userId); } @DeleteMapping - public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, @RequestHeader("X-USER-ID")Long id) { - return tagDefService.removeTagName(req, id); + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return tagDefService.removeTagName(req, userId); } } From 21f467d6a6927fd32386062f8b656465b87abb3d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 30 Aug 2025 23:51:06 +0900 Subject: [PATCH 135/527] refactor: rename purchasePiaItem to controller --- .../java/com/scriptopia/demo/controller/PiaShopController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 0e24abc6..d3f45d3b 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -44,7 +44,7 @@ public ResponseEntity> getPiaItems() { } - @PostMapping("/user/shops/pia/orders") + @PostMapping("/user/shops/pia/item/purchase") public ResponseEntity purchasePiaItem( @RequestBody PurchasePiaItemRequest requestDto, Authentication authentication) { From 51ea48794c924b074f4993ca87fed8a51546f593 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 31 Aug 2025 01:30:08 +0900 Subject: [PATCH 136/527] feature/myshared-game add --- .../controller/UserHistoryController.java | 6 +-- .../dto/sharedgame/MySharedGameResponse.java | 26 ++++++++++++ .../demo/repository/GameTagRepository.java | 6 +++ .../demo/repository/SharedGameRepository.java | 3 ++ .../demo/service/SharedGameService.java | 40 +++++++++++++++++-- 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java index 94c72d0f..78919da0 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java @@ -11,7 +11,7 @@ import java.util.List; @RestController -@RequestMapping("/public") +@RequestMapping("/users") @RequiredArgsConstructor public class UserHistoryController { private final HistoryService historyService; @@ -19,8 +19,8 @@ public class UserHistoryController { @GetMapping("/history") public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "10") int size, - @RequestParam Long userId) { -// Long userId = Long.valueOf(authentication.getName()); + Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); return historyService.fetchMyHisotry(userId, lastId, size); } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java new file mode 100644 index 00000000..5e2c6a2d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.dto.sharedgame; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class MySharedGameResponse { + private String thumbnailUrl; + private Long totalPlayed; + private String title; + private String worldView; + private String backgroundStory; + private LocalDateTime sharedAt; + private List tags; + + @Data + public static class TagDto { + private String tagName; + + public TagDto(String tagName) { + this.tagName = tagName; + } + } +} diff --git a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java index 1b8cda84..84aef9be 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java @@ -1,6 +1,8 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.GameTag; +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,4 +14,8 @@ public interface GameTagRepository extends JpaRepository { "from GameTag gt " + "where gt.sharedGame.id = :sharedGameId") List findTagNamesBySharedGameId(@Param("sharedGameId") Long sharedGameId); + + @Query("select new com.scriptopia.demo.dto.TagDef.TagDefCreateRequest(gt.tagDef.tagName) " + + "from GameTag gt where gt.sharedGame.id = :sharedGameId") + List findTagsBySharedGameId(@Param("sharedGameId") Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index b87a9d3e..5ad86374 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -4,5 +4,8 @@ import com.scriptopia.demo.domain.SharedGame; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface SharedGameRepository extends JpaRepository { + List findAllByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 94c13c08..8233d089 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -3,10 +3,12 @@ import com.scriptopia.demo.domain.History; import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; -import com.scriptopia.demo.repository.HistoryRepository; -import com.scriptopia.demo.repository.SharedGameRepository; -import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.*; import com.scriptopia.demo.utils.JwtProvider; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -14,13 +16,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class SharedGameService { private final SharedGameRepository sharedGameRepository; private final HistoryRepository historyRepository; - private final JwtProvider jwtProvider; private final UserRepository userRepository; + private final SharedGameScoreRepository sharedGameScoreRepository; + private final GameTagRepository gameTagRepository; @Transactional public ResponseEntity saveSharedGame(Long Id, Long historyId) { @@ -38,6 +43,33 @@ public ResponseEntity saveSharedGame(Long Id, Long historyId) { return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); } + public ResponseEntity getMySharedGames(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + List games = sharedGameRepository.findAllByUserId(user.getId()); + + List responses = games.stream().map(game -> { + MySharedGameResponse dto = new MySharedGameResponse(); + dto.setThumbnailUrl(game.getThumbnailUrl()); + dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); + dto.setTitle(game.getTitle()); + dto.setWorldView(game.getWorldView()); + dto.setBackgroundStory(game.getBackgroundStory()); + dto.setSharedAt(game.getSharedAt()); + + List names = gameTagRepository.findTagNamesBySharedGameId(game.getId()); + dto.setTags( + names.stream() + .map(MySharedGameResponse.TagDto::new) + .toList() + ); + return dto; + }).toList(); + + return ResponseEntity.ok(responses); + } + @Transactional public void deletesharedGame(Long id, Long sharedId) { User user = userRepository.findById(id) From 0e38ff9a4da06c2e596095d07024efad6971250d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 31 Aug 2025 02:01:10 +0900 Subject: [PATCH 137/527] feature/myshared-game get Controller add and test --- .../scriptopia/demo/controller/SharedGameController.java | 7 +++++++ .../scriptopia/demo/repository/GameSessionRepository.java | 6 ++---- .../com/scriptopia/demo/service/GameSessionService.java | 4 ++-- .../com/scriptopia/demo/service/SharedGameService.java | 4 ---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index c3993ba7..a3398dd9 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -19,6 +19,13 @@ public ResponseEntity share(Authentication authentication, @PathVariable Long return sharedGameService.saveSharedGame(userId, hid); } + @GetMapping("/games/shared") + public ResponseEntity getMySharedGames(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.getMySharedGames(userId); + } + @DeleteMapping("/share/{gameid}") public void delete(Authentication authentication, @PathVariable Long gameid) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index 8bc317ea..107480c7 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -7,9 +7,7 @@ import java.util.Optional; public interface GameSessionRepository extends JpaRepository { - Optional findBySessionId(String sessionId); + Optional findByUser_IdAndMongoId(Long userId, String mongoId); - Optional findByMongoId(String mongoId); - - List findAllByUserId(Long userId); + List findAllByUser_Id(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d26649d3..f440576d 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -23,7 +23,7 @@ public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - var sessions = gameSessionRepository.findAllByUserId(user.getId()); + var sessions = gameSessionRepository.findAllByUser_Id(user.getId()); var dtos = sessions.stream().map(s -> { var dto = new GameSessionResponse(); dto.setId(s.getId()); @@ -50,7 +50,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - GameSession gameSession = gameSessionRepository.findBySessionId(sessionId) + GameSession gameSession = gameSessionRepository.findByUser_IdAndMongoId(user.getId(), sessionId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); gameSessionRepository.delete(gameSession); diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 8233d089..2564fb95 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -3,14 +3,10 @@ import com.scriptopia.demo.domain.History; import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.User; -import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; -import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; -import com.scriptopia.demo.utils.JwtProvider; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; From 88b7be66b3e2e1a2edbdec3e115824eea6326a7e Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 02:23:27 +0900 Subject: [PATCH 138/527] merge --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index e92e6cd7..0c1dbc45 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -73,4 +73,4 @@ public enum ErrorCode { private final String message; private final HttpStatus status; -} +} \ No newline at end of file From 37012e125fa219549666e8fb5ca6d6edadf4c84c Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 02:45:59 +0900 Subject: [PATCH 139/527] feat: implement send reset link send mail when request for password initialization --- .../scriptopia/demo/service/MailService.java | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/MailService.java b/src/main/java/com/scriptopia/demo/service/MailService.java index b8ab7b0c..af3837f8 100644 --- a/src/main/java/com/scriptopia/demo/service/MailService.java +++ b/src/main/java/com/scriptopia/demo/service/MailService.java @@ -1,5 +1,8 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.LocalAccountRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; @@ -14,17 +17,21 @@ public class MailService { private final JavaMailSender mailSender; private final StringRedisTemplate redisTemplate; + private final LocalAccountRepository localAccountRepository; @Value("${spring.mail.username}") private String fromEmail; + @Value("${reset-url}") + private String resetUrl; + public void sendVerificationCode(String toEmail, String code) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setTo(toEmail); - message.setFrom(fromEmail); - message.setSubject("회원가입 이메일 인증번호"); - message.setText("인증번호: " + code + "\n5분 이내에 입력해주세요."); - mailSender.send(message); + mailSender.send(initMessage( + toEmail, + fromEmail, + "[Scriptopia] 회원가입 이메일 인증번호", + "인증번호: " + code + "\n5분 이내에 입력해주세요." + )); } public void saveCode(String email, String code) { @@ -36,6 +43,26 @@ public void saveCode(String email, String code) { ); } + public void sendResetLink(String toEmail, String token) { + + String link = resetUrl + "?token=" + token; + mailSender.send(initMessage( + toEmail, + fromEmail, + "[Scriptopia] 비밀번호 재설정 안내", + "아래 링크를 클릭하여 비밀번호를 변경하세요:\n" +link + )); + } + + + public SimpleMailMessage initMessage(String toEmail, String fromEmail, String subject, String text) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setFrom(fromEmail); + message.setSubject(subject); + message.setText(text); + return message; + } public String getCode(String email) { return redisTemplate.opsForValue().get("email:verify" + email); } From 911c1cfbf9a41430cc8fa17123cfe07efe5b8755 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 02:56:03 +0900 Subject: [PATCH 140/527] feat: implement password reset mail service generate reset token and store in redis send reset ling to user email --- .../demo/service/LocalAccountService.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 99f03912..da79f11b 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -22,6 +22,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Locale; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; @@ -47,6 +48,9 @@ public class LocalAccountService { private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); + private static final long TOKEN_EXPIRATION = 30L; + + @Transactional public void verifyEmail(VerifyEmailRequest request) { @@ -65,6 +69,17 @@ public void sendVerificationCode(String email) { mailService.sendVerificationCode(email, code); } + @Transactional + public void sendResetPasswordMail(String email) { + + if (!localAccountRepository.existsByEmail(email)){ + throw new CustomException(ErrorCode.E_404_USER_NOT_FOUND); + } + + String resetToken = createResetToken(email); + mailService.sendResetLink(email, resetToken); + } + @Transactional public void verifyCode(String email, String inputCode) { @@ -76,16 +91,17 @@ public void verifyCode(String email, String inputCode) { if (savedCode != null && savedCode.equals(inputCode)) { // 인증 완료 후 30분 유지 - redisTemplate.opsForValue().set("email:verified:" + email, "true", 30, TimeUnit.MINUTES); + redisTemplate.opsForValue().set("email:verified:" + email, "true", TOKEN_EXPIRATION, TimeUnit.MINUTES); redisTemplate.delete("email:verify:" + email); // 코드 제거 } else{ throw new CustomException(ErrorCode.E_401_CODE_MISMATCH); } - } + + @Transactional public void register(RegisterRequest request) { String email = request.getEmail(); @@ -197,6 +213,15 @@ public void changePassword(Long userId, ChangePasswordRequest request) { } + public String createResetToken(String email) { + String token = UUID.randomUUID().toString(); + + redisTemplate.opsForValue() + .set("reset:token" + token, email, TOKEN_EXPIRATION, TimeUnit.MINUTES); + + return token; + } + public List getRoles(Long userId) { From 18dab1490b4bfaffe68859337712d021707aea1f Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 03:00:39 +0900 Subject: [PATCH 141/527] feat: add send reset mail request dto --- .../dto/localaccount/SendResetMailRequest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java new file mode 100644 index 00000000..fa40c1e3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SendResetMailRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; +} From 95198260049f5ecf1dc2c6f7e4c412f459bd355a Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 04:02:33 +0900 Subject: [PATCH 142/527] feat: create send reset password mail api --- .../com/scriptopia/demo/controller/AuthController.java | 8 ++++++++ .../com/scriptopia/demo/service/LocalAccountService.java | 2 +- src/main/resources/application.yml | 7 +++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 49f6db80..a05da5de 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -31,6 +31,14 @@ public class AuthController { private static final String COOKIE_SAMESITE = "None"; + @PostMapping("/public/auth/password/send-link") + public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ + + localAccountService.sendResetPasswordMail(request.getEmail()); + + return ResponseEntity.ok("비밀번호 초기화 링크를 전송했습니다."); + } + @PostMapping("/public/auth/verify-email") public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index da79f11b..1dba71d4 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -48,7 +48,7 @@ public class LocalAccountService { private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); - private static final long TOKEN_EXPIRATION = 30L; + private static final long TOKEN_EXPIRATION = 30; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59fe91b1..c4ff81b3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,9 @@ spring: data: mongodb: uri: mongodb://root:tiger@localhost:27017/scriptopia_mongo?authSource=admin + redis: + host: 127.0.0.1 + port: 6379 mail: host: smtp.gmail.com port: 587 @@ -28,16 +31,16 @@ spring: password: ${SPRING_MAIL_PASSWORD} properties: mail: + debug: true smtp: auth: true starttls: enable: true + auth: jwt: issuer: scriptopia access-exp-seconds: 1800 refresh-exp-seconds: 1209600 secret: ${JWT_SECRET} - - From 07697eb9967dad8f49fc9c019ea738cf6acf3405 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 04:09:04 +0900 Subject: [PATCH 143/527] feat: add ResetPasswordRequest dto for reset password --- .../localaccount/ResetPasswordRequest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java new file mode 100644 index 00000000..ccb160d1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java @@ -0,0 +1,24 @@ +package com.scriptopia.demo.dto.localaccount; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ResetPasswordRequest { + + private String token; + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", + message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + ) + private String newPassword; +} From f82808fb97599183fe5311a37b252dd6b926f7d0 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 04:15:17 +0900 Subject: [PATCH 144/527] implement resetpassword functionality --- .../demo/service/LocalAccountService.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 1dba71d4..4e849706 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -52,6 +52,23 @@ public class LocalAccountService { + @Transactional + public void resetPassword(String token,String newPassword) { + String key = "reset:token:" + token; + String email = redisTemplate.opsForValue().get(key); + + if (email == null) { + throw new CustomException(ErrorCode.E_401); + } + + LocalAccount localAccount = localAccountRepository.findByEmail(email).orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + localAccount.setPassword(passwordEncoder.encode(newPassword)); + localAccountRepository.save(localAccount); + + redisTemplate.delete(key); + } + @Transactional public void verifyEmail(VerifyEmailRequest request) { String email = request.getEmail(); From fb9b058b9598f7dd567a5653503d2a25911b9974 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 04:28:07 +0900 Subject: [PATCH 145/527] feat: add password reset endpoints with Redis token flow --- .../com/scriptopia/demo/controller/AuthController.java | 7 +++++++ .../com/scriptopia/demo/service/LocalAccountService.java | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index a05da5de..1d94d2ee 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -31,6 +31,13 @@ public class AuthController { private static final String COOKIE_SAMESITE = "None"; + @PatchMapping("public/auth/password/reset") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + localAccountService.resetPassword(request.getToken(), request.getNewPassword()); + + return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); + } + @PostMapping("/public/auth/password/send-link") public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 4e849706..a36b4c13 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -56,7 +56,7 @@ public class LocalAccountService { public void resetPassword(String token,String newPassword) { String key = "reset:token:" + token; String email = redisTemplate.opsForValue().get(key); - + System.out.println(key); if (email == null) { throw new CustomException(ErrorCode.E_401); } @@ -234,7 +234,7 @@ public String createResetToken(String email) { String token = UUID.randomUUID().toString(); redisTemplate.opsForValue() - .set("reset:token" + token, email, TOKEN_EXPIRATION, TimeUnit.MINUTES); + .set("reset:token:" + token, email, TOKEN_EXPIRATION, TimeUnit.MINUTES); return token; } From 36de6f454479c07dfa07c185abc3e48be9e65ec0 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 04:32:45 +0900 Subject: [PATCH 146/527] refactor: remove redis dibug option --- src/main/resources/application.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c4ff81b3..ddacbe7c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,7 +31,6 @@ spring: password: ${SPRING_MAIL_PASSWORD} properties: mail: - debug: true smtp: auth: true starttls: From 0045859362ee714940f0d6e6bfedf41d0bcf6891 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:05:17 +0900 Subject: [PATCH 147/527] feat: create StartGameRequest --- .../demo/controller/GameSessionController.java | 11 +++++++++++ .../demo/dto/gamesession/StartGameRequest.java | 14 ++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 0cda05f5..85a7e21b 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -36,4 +36,15 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV return gameSessionService.deleteGameSession(userId, sessionId); } + + + // 게임 시작 + @PostMapping + public ResponseEntity startNewGame( + @RequestBody StartGameRequest request, + Authentication authentication) { + + gameSessionService.startNewGame(request); + return ResponseEntity.ok("새 게임이 시작되었습니다."); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java new file mode 100644 index 00000000..4f5c8b34 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StartGameRequest { + private String background; + private String characterName; + private String characterDescription; +} \ No newline at end of file From e065dbb1db18e9ee97487e2e3db8eeeb58d4f96d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:08:17 +0900 Subject: [PATCH 148/527] feat: create StartGameRequest --- .../demo/controller/GameSessionController.java | 3 +++ .../demo/dto/gamesession/startNewGameRequest.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/startNewGameRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 85a7e21b..fb1a3fd1 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.dto.gamesession.GameSessionRequest; import com.scriptopia.demo.dto.gamesession.GameSessionResponse; +import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.service.GameSessionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -47,4 +48,6 @@ public ResponseEntity startNewGame( gameSessionService.startNewGame(request); return ResponseEntity.ok("새 게임이 시작되었습니다."); } + + } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/startNewGameRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/startNewGameRequest.java new file mode 100644 index 00000000..98448686 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/startNewGameRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class startNewGameRequest { + private String message; + private String mongoId; + +} From 37f189a78505f014265ddbf479998adcd2e680a6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:15:41 +0900 Subject: [PATCH 149/527] feat: create ExternalGameData dto --- .../controller/GameSessionController.java | 7 +- .../dto/gamesession/ExternalGameData.java | 78 +++++++++++++++++++ .../demo/service/GameSessionService.java | 5 ++ 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index fb1a3fd1..9b599c1c 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -41,12 +41,13 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV // 게임 시작 @PostMapping - public ResponseEntity startNewGame( + public ResponseEntity startNewGame( @RequestBody StartGameRequest request, Authentication authentication) { - gameSessionService.startNewGame(request); - return ResponseEntity.ok("새 게임이 시작되었습니다."); + Long userId = Long.valueOf(authentication.getName()); + StartGameRequest response = gameSessionService.startNewGame(userId, request); + return ResponseEntity.ok(response); } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java new file mode 100644 index 00000000..7460bed9 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java @@ -0,0 +1,78 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ExternalGameData { + + private PlayerInfo player_info; + private List inventory; + private List item_def; + private String world_view; + private String background_story; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class PlayerInfo { + private String name; + private int life; + private int level; + private int experience_point; + private int combat_point; + private int health_point; + private String trait; + private int strength; + private int agility; + private int intelligence; + private int luck; + private int gold; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class InventoryItem { + private int item_def_id; + private String acquired_at; + private boolean equipped; + private String source; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ItemDef { + private int item_def_id; + private String item_pic_src; + private String name; + private String description; + private String category; + private int base_stat; + private List item_effect; + private int strength; + private int agility; + private int intelligence; + private int luck; + private String main_stat; + private int weight; + private String grade; + private int price; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ItemEffect { + private String item_effect_name; + private String item_effect_description; + private String grade; + private int item_effect_weight; + } + } +} diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f440576d..99c8ff43 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -56,4 +56,9 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { return ResponseEntity.ok("선택하신 게임이 삭제되었습니다."); } + + + startNewGame + + } From 0c9fa99b21a7435d80c52a529fef15674f8f4569 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:16:36 +0900 Subject: [PATCH 150/527] refactor: change name ExternalGameResponse --- .../{ExternalGameData.java => ExternalGameResponse.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/scriptopia/demo/dto/gamesession/{ExternalGameData.java => ExternalGameResponse.java} (98%) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java similarity index 98% rename from src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java rename to src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 7460bed9..81dacefb 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameData.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -9,7 +9,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class ExternalGameData { +public class ExternalGameResponse { private PlayerInfo player_info; private List inventory; From 3dd9adf17a90a2c58e683d101a55090b8ced24c4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:31:17 +0900 Subject: [PATCH 151/527] feat: create BattleInfoMongo create BattleTurnMongo create ChoiceInfoMongo create ChoiceMongo create DoneInfoMongo create GameSessionMongo create HistoryInfoMongo create InventoryItemMongo create ItemDefMongo createItemEffectMongo create PlayerInfoMongo create RewardInfoMongo create ShopInfoMongo --- .../demo/domain/mongo/BattleInfoMongo.java | 15 +++++++ .../demo/domain/mongo/BattleTurnMongo.java | 11 +++++ .../demo/domain/mongo/ChoiceInfoMongo.java | 14 +++++++ .../demo/domain/mongo/ChoiceMongo.java | 14 +++++++ .../demo/domain/mongo/DoneInfoMongo.java | 10 +++++ .../demo/domain/mongo/GameSessionMongo.java | 40 +++++++++++++++++++ .../demo/domain/mongo/HistoryInfoMongo.java | 20 ++++++++++ .../demo/domain/mongo/InventoryItemMongo.java | 15 +++++++ .../demo/domain/mongo/ItemDefMongo.java | 26 ++++++++++++ .../demo/domain/mongo/ItemEffectMongo.java | 13 ++++++ .../demo/domain/mongo/PlayerInfoMongo.java | 21 ++++++++++ .../demo/domain/mongo/RewardInfoMongo.java | 20 ++++++++++ .../demo/domain/mongo/ShopInfoMongo.java | 12 ++++++ 13 files changed, 231 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java new file mode 100644 index 00000000..b0c14daf --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class BattleInfoMongo { + private Long curTurnId; + private List playerHp; + private List enemyHp; + private List battleTurn; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java new file mode 100644 index 00000000..df435b65 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java @@ -0,0 +1,11 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BattleTurnMongo { + private Integer turnId; + private String turnInfo; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java new file mode 100644 index 00000000..9a9c6399 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ChoiceInfoMongo { + private String eventType; // living, nonliving + private String story; + private List choice; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java new file mode 100644 index 00000000..de80afc7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + + +@Getter +@Setter +public class ChoiceMongo { + private String detail; + private String stats; // strength, agility, intelligence, luck + private Integer probability; + private String resultType; // battle, reward, shop, none +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java new file mode 100644 index 00000000..59d8a939 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java @@ -0,0 +1,10 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DoneInfoMongo { + private String story; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java new file mode 100644 index 00000000..3b28cf98 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -0,0 +1,40 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Document(collection = "game_sessions") +public class GameSessionMongo { + + @Id + private String id; // MongoDB 기본키 + + private Long userId; // MySQL 사용자 ID + + private String sceneType; // battle, choice, shop, done + + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + + private String background; + private Integer progress; + private List stage; + + private PlayerInfoMongo playerInfo; + private List inventory; + private List itemDef; + + private ChoiceInfoMongo choiceInfo; + private DoneInfoMongo doneInfo; + private ShopInfoMongo shopInfo; + private BattleInfoMongo battleInfo; + private RewardInfoMongo rewardInfo; + private HistoryInfoMongo historyInfo; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java new file mode 100644 index 00000000..80f5cf76 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java @@ -0,0 +1,20 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class HistoryInfoMongo { + private String title; + private String worldView; + private String backgroundStory; + private String worldPrompt; + private String epilogue1Title; + private String epilogue1Content; + private String epilogue2Title; + private String epilogue2Content; + private String epilogue3Title; + private String epilogue3Content; + private Integer score; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java new file mode 100644 index 00000000..4eb03b84 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class InventoryItemMongo { + private Long itemDefId; + private LocalDateTime acquiredAt; + private Boolean equipped; + private String source; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java new file mode 100644 index 00000000..dd19c227 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ItemDefMongo { + private Long itemDefId; + private String itemPicSrc; + private String name; + private String description; + private String category; // WEAPON, ARMOR, ARTIFACT, POTION + private Integer baseStat; + private List itemEffect; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private String mainStat; // strength, agility, intelligence, luck + private Integer weight; + private String grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY + private Integer price; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java new file mode 100644 index 00000000..232ab2bf --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ItemEffectMongo { + private String itemEffectName; + private String itemEffectDescription; + private String grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY + private Integer itemEffectWeight; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java new file mode 100644 index 00000000..f4311bf9 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PlayerInfoMongo { + private String name; + private Integer life; + private Integer level; + private Integer experiencePoint; + private Integer combatPoint; + private Integer healthPoint; + private String trait; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private Integer gold; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java new file mode 100644 index 00000000..c919e2b1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java @@ -0,0 +1,20 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class RewardInfoMongo { + private List gainedItemDefId; + private List lostItemsDefId; + private Integer rewardStrength; + private Integer rewardAgility; + private Integer rewardIntelligence; + private Integer rewardLuck; + private Integer rewardLife; + private String rewardTrait; + private Integer rewardGold; +} diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java new file mode 100644 index 00000000..97efafbf --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ShopInfoMongo { + private List itemDefId; +} From 8616374364256c7f8938e6076a8172aba007ee9e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:35:55 +0900 Subject: [PATCH 152/527] feat: create ENUM type create ChoiceEventType create ChoiceResultType create ItemCategory create SceneType : --- .../java/com/scriptopia/demo/domain/ChoiceEventType.java | 5 +++++ .../java/com/scriptopia/demo/domain/ChoiceResultType.java | 5 +++++ src/main/java/com/scriptopia/demo/domain/ItemCategory.java | 5 +++++ src/main/java/com/scriptopia/demo/domain/SceneType.java | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java create mode 100644 src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java create mode 100644 src/main/java/com/scriptopia/demo/domain/ItemCategory.java create mode 100644 src/main/java/com/scriptopia/demo/domain/SceneType.java diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java new file mode 100644 index 00000000..26b1bd02 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum ChoiceEventType { + LIVING, NONLIVING +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java new file mode 100644 index 00000000..fbd4d169 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum ChoiceResultType { + BATTLE, REWARD, SHOP, NONE +} diff --git a/src/main/java/com/scriptopia/demo/domain/ItemCategory.java b/src/main/java/com/scriptopia/demo/domain/ItemCategory.java new file mode 100644 index 00000000..02d53890 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/ItemCategory.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum ItemCategory { + WEAPON, ARMOR, ARTIFACT, POTION +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/SceneType.java b/src/main/java/com/scriptopia/demo/domain/SceneType.java new file mode 100644 index 00000000..b001a92c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/SceneType.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.domain; + +public enum SceneType { + BATTLE, CHOICE, SHOP, DONE +} From af4237ad7b7aa875120cbaf7ce4875b615fa2b0d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 17:40:23 +0900 Subject: [PATCH 153/527] feat: configure Google OAuth2 client in application yml --- src/main/resources/application.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ddacbe7c..ebc5b916 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,21 @@ spring: starttls: enable: true + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_SECRET_KEY} + scope: + - email + - profile + provider: + google: + issuer-uri: https://accounts.google.com + + auth: jwt: From 17d538632bd2e86d2d59f7d285b21097e3ad05a3 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 17:41:43 +0900 Subject: [PATCH 154/527] feat: add dependence oauth2 for social login --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 24047715..69ca97f8 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { //이메일 인증 implementation 'org.springframework.boot:spring-boot-starter-mail' + + // 소셜 로그인 oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { From ba290eee7b9981ecbe8f6dcd544fcf6263d595ae Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 17:43:26 +0900 Subject: [PATCH 155/527] refactor: change mongo domain change to enum type ChoiceInfoMongo change to enum type ChoiceMongo change to enum type GameSessionMongo change to enum type ItemDefMongo change to enum type ItemEffectMongo --- .../scriptopia/demo/domain/mongo/ChoiceInfoMongo.java | 3 ++- .../com/scriptopia/demo/domain/mongo/ChoiceMongo.java | 6 ++++-- .../scriptopia/demo/domain/mongo/GameSessionMongo.java | 3 ++- .../com/scriptopia/demo/domain/mongo/ItemDefMongo.java | 9 ++++++--- .../scriptopia/demo/domain/mongo/ItemEffectMongo.java | 3 ++- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java index 9a9c6399..2ba73a38 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.ChoiceEventType; import lombok.Getter; import lombok.Setter; @@ -8,7 +9,7 @@ @Getter @Setter public class ChoiceInfoMongo { - private String eventType; // living, nonliving + private ChoiceEventType eventType; // living, nonliving private String story; private List choice; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index de80afc7..135f779b 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.ChoiceResultType; +import com.scriptopia.demo.domain.MainStat; import lombok.Getter; import lombok.Setter; @@ -8,7 +10,7 @@ @Setter public class ChoiceMongo { private String detail; - private String stats; // strength, agility, intelligence, luck + private MainStat stats; // strength, agility, intelligence, luck private Integer probability; - private String resultType; // battle, reward, shop, none + private ChoiceResultType resultType; // battle, reward, shop, none } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 3b28cf98..77693413 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.SceneType; import lombok.Getter; import lombok.Setter; import org.springframework.data.annotation.Id; @@ -18,7 +19,7 @@ public class GameSessionMongo { private Long userId; // MySQL 사용자 ID - private String sceneType; // battle, choice, shop, done + private SceneType sceneType; // battle, choice, shop, done private LocalDateTime startedAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index dd19c227..e15c40f3 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -1,5 +1,8 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemCategory; +import com.scriptopia.demo.domain.MainStat; import lombok.Getter; import lombok.Setter; @@ -12,15 +15,15 @@ public class ItemDefMongo { private String itemPicSrc; private String name; private String description; - private String category; // WEAPON, ARMOR, ARTIFACT, POTION + private ItemCategory category; // WEAPON, ARMOR, ARTIFACT, POTION private Integer baseStat; private List itemEffect; private Integer strength; private Integer agility; private Integer intelligence; private Integer luck; - private String mainStat; // strength, agility, intelligence, luck + private MainStat mainStat; // strength, agility, intelligence, luck private Integer weight; - private String grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY + private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY private Integer price; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java index 232ab2bf..4f5bea06 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.Grade; import lombok.Getter; import lombok.Setter; @@ -8,6 +9,6 @@ public class ItemEffectMongo { private String itemEffectName; private String itemEffectDescription; - private String grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY + private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY private Integer itemEffectWeight; } From b52814dd108887815b0925fcc9a91dae540c7ade Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:02:18 +0900 Subject: [PATCH 156/527] feat: add OAuthUserInfo dto for google login --- .../demo/controller/OAuthController.java | 12 ++++++++++++ .../scriptopia/demo/dto/oauth/OAUthUserInfo.java | 15 +++++++++++++++ .../com/scriptopia/demo/service/OAuthService.java | 11 +++++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/OAuthController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java create mode 100644 src/main/java/com/scriptopia/demo/service/OAuthService.java diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java new file mode 100644 index 00000000..63eae722 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/public/oauth") +@RequiredArgsConstructor +public class OAuthController { + +} diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java new file mode 100644 index 00000000..508f5015 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.oauth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OAUthUserInfo { + private String id; + private String email; + private String name; + private String profileImage; +} diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java new file mode 100644 index 00000000..cc4b5713 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -0,0 +1,11 @@ +package com.scriptopia.demo.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OAuthService { + + +} From 55706b51bbfa778d58e36dd1492fac642de47ba2 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:11:45 +0900 Subject: [PATCH 157/527] feat: update social account entity add email attribute --- src/main/java/com/scriptopia/demo/domain/SocialAccount.java | 2 ++ .../demo/dto/oauth/{OAUthUserInfo.java => OAuthUserInfo.java} | 0 2 files changed, 2 insertions(+) rename src/main/java/com/scriptopia/demo/dto/oauth/{OAUthUserInfo.java => OAuthUserInfo.java} (100%) diff --git a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java index 623ed2ca..2b7b27b6 100644 --- a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java +++ b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java @@ -19,6 +19,8 @@ public class SocialAccount{ private String socialId; + private String email; + @Enumerated(EnumType.STRING) private Provider provider; } diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java similarity index 100% rename from src/main/java/com/scriptopia/demo/dto/oauth/OAUthUserInfo.java rename to src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java From 3bfbdebd047f0d7d4b092c2dd95b7816421ec425 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 18:23:04 +0900 Subject: [PATCH 158/527] feat: create GameSessionMongoRepository to repository --- .../demo/repository/GameSessionMongoRepository.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java new file mode 100644 index 00000000..3fe450a8 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.repository; + +import com.scriptopia.demo.domain.mongo.GameSessionMongo; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface GameSessionMongoRepository extends MongoRepository { + + + // userId로 진행 중인 게임 조회 + Optional findByUserIdAndSceneTypeNot(String userId, String sceneType); +} \ No newline at end of file From ed2ec0f5c1e6a1d23812cf87a28fa241d0f9caf2 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:24:32 +0900 Subject: [PATCH 159/527] feat: create login type enum class --- src/main/java/com/scriptopia/demo/domain/LoginType.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/LoginType.java diff --git a/src/main/java/com/scriptopia/demo/domain/LoginType.java b/src/main/java/com/scriptopia/demo/domain/LoginType.java new file mode 100644 index 00000000..bc506a3b --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/LoginType.java @@ -0,0 +1,6 @@ +package com.scriptopia.demo.domain; + + +public enum LoginType { + LOCAL, SOCIAL +} From c34a2cd21f2cb78f6db72e4b69841fb6973c78cf Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:25:29 +0900 Subject: [PATCH 160/527] feat: update user entity add attribute loginType --- src/main/java/com/scriptopia/demo/domain/User.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index 2c993001..db76c4e1 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -29,6 +29,8 @@ public class User { @Enumerated(EnumType.STRING) private Role role; + @Enumerated(EnumType.STRING) + private LoginType loginType; // 거래 관련 도메인 메소드 public void addPia(Long amount) { From 30dccffd964f2aea050d15c9f1fe0049cb86d87b Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:26:08 +0900 Subject: [PATCH 161/527] feat: seperate oauth in yml --- src/main/resources/application.yml | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebc5b916..e6c28214 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,19 +36,11 @@ spring: starttls: enable: true - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_SECRET_KEY} - scope: - - email - - profile - provider: - google: - issuer-uri: https://accounts.google.com +oauth: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_SECRET_KEY} + redirect-uri: ${GOOGLE_REDIRECT_URI} From ccd75184acc4aa516fed0d199fbc57f836f8d51b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 18:28:17 +0900 Subject: [PATCH 162/527] feat: existsByUserIdAndSceneTypeNo create E_400_GAME_ALREADY_IN_PROGRESS to custom error --- .../scriptopia/demo/exception/ErrorCode.java | 1 + .../repository/GameSessionRepository.java | 2 + .../demo/service/GameSessionService.java | 128 +++++++++++++++++- 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index cdb3d841..66f2d473 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -31,6 +31,7 @@ public enum ErrorCode { E_400_MISSING_JWT("E400018", "토큰 값이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_PIA_ITEM_DUPLICATE("E400019", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_REQUEST("E400020", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), + E_400_GAME_ALREADY_IN_PROGRESS("E400021", "진행 중인 게임이 이미 존재합니다.", HttpStatus.BAD_REQUEST), diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index 107480c7..d20f2762 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -10,4 +10,6 @@ public interface GameSessionRepository extends JpaRepository Optional findByUser_IdAndMongoId(Long userId, String mongoId); List findAllByUser_Id(Long userId); + + boolean existsByUserIdAndSceneTypeNot(Long userId, String sceneType); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 99c8ff43..0a57c822 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -4,6 +4,7 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.dto.gamesession.GameSessionRequest; import com.scriptopia.demo.dto.gamesession.GameSessionResponse; +import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.GameSessionRepository; @@ -58,7 +59,132 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { } - startNewGame + public String startNewGame(Long userId, StartGameRequest request) { + + // 1. 진행중인 게임 체크 + if (gameSessionRepository.existsByUserIdAndSceneTypeNotDone(userId)) { + throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); + } + + // 2. FastAPI 호출 + String url = "http://localhost:8000/games/init"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String requestBody = String.format( + "{\"background\":\"%s\",\"characterName\":\"%s\",\"characterDescription\":\"%s\"}", + background, characterName, characterDescription + ); + + HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); + + ResponseEntity responseEntity = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, ExternalGameResponse.class); + + ExternalGameResponse externalGame = responseEntity.getBody(); + + if (externalGame == null) { + throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); + } + + // 3. 밸런스 재세팅 (여기서 필요한 값 수정 가능) + // 예: life, gold 등 초기화 + externalGame.getPlayer_info().setLife(100); + externalGame.getPlayer_info().setGold(100); + + // 4. MongoDB 저장 + GameSessionMongo mongoSession = new GameSessionMongo(); + mongoSession.setUserId(userId); + mongoSession.setSceneType("choice"); // 시작은 choice 등 기본값 + mongoSession.setStartedAt(LocalDateTime.now()); + mongoSession.setUpdatedAt(LocalDateTime.now()); + mongoSession.setBackground(background); + mongoSession.setPlayerInfo(convertPlayerInfo(externalGame)); + mongoSession.setInventory(convertInventory(externalGame)); + mongoSession.setItemDef(convertItemDef(externalGame)); + mongoSession.setProgress(0); + + GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); + + // 5. MySQL GameSession에 MongoDB PK 저장 + GameSession mysqlSession = new GameSession(); + mysqlSession.setUserId(userId); + mysqlSession.setMongoId(savedMongo.getId()); + mysqlSession.setSceneType("choice"); + mysqlSession.setStartedAt(LocalDateTime.now()); + mysqlSession.setUpdatedAt(LocalDateTime.now()); + gameSessionRepository.save(mysqlSession); + + // 6. MongoDB PK 반환 + return savedMongo.getId(); + } + + private GameSessionMongo.PlayerInfoMongo convertPlayerInfo(ExternalGameResponse external) { + GameSessionMongo.PlayerInfoMongo info = new GameSessionMongo.PlayerInfoMongo(); + ExternalGameResponse.PlayerInfo p = external.getPlayer_info(); + info.setName(p.getName()); + info.setLife(p.getLife()); + info.setLevel(p.getLevel()); + info.setExperiencePoint(p.getExperience_point()); + info.setCombatPoint(p.getCombat_point()); + info.setHealthPoint(p.getHealth_point()); + info.setTrait(p.getTrait()); + info.setStrength(p.getStrength()); + info.setAgility(p.getAgility()); + info.setIntelligence(p.getIntelligence()); + info.setLuck(p.getLuck()); + info.setGold(p.getGold()); + return info; + } + + private java.util.List convertInventory(ExternalGameResponse external) { + java.util.List list = new java.util.ArrayList<>(); + for (ExternalGameResponse.InventoryItem item : external.getInventory()) { + GameSessionMongo.InventoryItemMongo mongoItem = new GameSessionMongo.InventoryItemMongo(); + mongoItem.setItemDefId(item.getItem_def_id()); + mongoItem.setAcquiredAt(LocalDateTime.parse(item.getAcquired_at())); + mongoItem.setEquipped(item.isEquipped()); + mongoItem.setSource(item.getSource()); + list.add(mongoItem); + } + return list; + } + + private java.util.List convertItemDef(ExternalGameResponse external) { + java.util.List list = new java.util.ArrayList<>(); + for (ExternalGameResponse.ItemDef def : external.getItem_def()) { + GameSessionMongo.ItemDefMongo mongoDef = new GameSessionMongo.ItemDefMongo(); + mongoDef.setItemDefId(def.getItem_def_id()); + mongoDef.setItemPicSrc(def.getItem_pic_src()); + mongoDef.setName(def.getName()); + mongoDef.setDescription(def.getDescription()); + mongoDef.setCategory(def.getCategory()); + mongoDef.setBaseStat(def.getBase_stat()); + mongoDef.setStrength(def.getStrength()); + mongoDef.setAgility(def.getAgility()); + mongoDef.setIntelligence(def.getIntelligence()); + mongoDef.setLuck(def.getLuck()); + mongoDef.setMainStat(def.getMain_stat()); + mongoDef.setWeight(def.getWeight()); + mongoDef.setGrade(def.getGrade()); + mongoDef.setPrice(def.getPrice()); + + java.util.List effectList = new java.util.ArrayList<>(); + for (ExternalGameResponse.ItemDef.ItemEffect eff : def.getItem_effect()) { + GameSessionMongo.ItemDefMongo.ItemEffectMongo effMongo = new GameSessionMongo.ItemDefMongo.ItemEffectMongo(); + effMongo.setItemEffectName(eff.getItem_effect_name()); + effMongo.setItemEffectDescription(eff.getItem_effect_description()); + effMongo.setGrade(eff.getGrade()); + effMongo.setItemEffectWeight(eff.getItem_effect_weight()); + effectList.add(effMongo); + } + mongoDef.setItemEffect(effectList); + + list.add(mongoDef); + } + return list; + } } From 79b6e873cc40cf37c81575b9cab0fd23345cbfd2 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:30:39 +0900 Subject: [PATCH 163/527] feat: update dto OAuthUserInfo rename to SocialSignUpRequest add socialId --- .../{OAuthUserInfo.java => SocialSignupRequest.java} | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) rename src/main/java/com/scriptopia/demo/dto/oauth/{OAuthUserInfo.java => SocialSignupRequest.java} (56%) diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java similarity index 56% rename from src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java rename to src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java index 508f5015..fa7fce0f 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java @@ -1,15 +1,17 @@ package com.scriptopia.demo.dto.oauth; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data +@Builder @AllArgsConstructor @NoArgsConstructor -public class OAUthUserInfo { - private String id; +public class SocialSignupRequest { + private String provider; + private String socialId; private String email; - private String name; - private String profileImage; + private String nickname; } From 3140c1fd693c5e6289ecf6f29b1b95ad546756b4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 18:34:54 +0900 Subject: [PATCH 164/527] feat: create CreateGameRequest for FAST API --- .../demo/dto/gamesession/CreateGameRequest.java | 14 ++++++++++++++ .../demo/dto/gamesession/StartGameRequest.java | 1 + .../demo/repository/GameSessionRepository.java | 2 +- .../demo/service/GameSessionService.java | 8 ++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameRequest.java new file mode 100644 index 00000000..3dbe1a8e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameRequest.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateGameRequest { + private String background; + private String characterName; + private String characterDescription; +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java index 4f5c8b34..ae8f10d3 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameRequest.java @@ -11,4 +11,5 @@ public class StartGameRequest { private String background; private String characterName; private String characterDescription; + private String itemId; // uuid를 받을 것 (임시임) } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index d20f2762..af328016 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -11,5 +11,5 @@ public interface GameSessionRepository extends JpaRepository List findAllByUser_Id(Long userId); - boolean existsByUserIdAndSceneTypeNot(Long userId, String sceneType); + boolean existsByUserIdAndSceneTypeNotDone(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 0a57c822..8253586e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.GameSession; import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; import com.scriptopia.demo.dto.gamesession.GameSessionRequest; import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.dto.gamesession.StartGameRequest; @@ -10,6 +11,9 @@ import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,7 +70,7 @@ public String startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); } - // 2. FastAPI 호출 + // 2. FastAPI 호출(테스트용 추후 변경 가능) String url = "http://localhost:8000/games/init"; HttpHeaders headers = new HttpHeaders(); @@ -74,7 +78,7 @@ public String startNewGame(Long userId, StartGameRequest request) { String requestBody = String.format( "{\"background\":\"%s\",\"characterName\":\"%s\",\"characterDescription\":\"%s\"}", - background, characterName, characterDescription + request ); HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); From 0ac60721980a484532f86cd97cf80d25fef4aa6d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:35:35 +0900 Subject: [PATCH 165/527] feat: create login status enum class --- src/main/java/com/scriptopia/demo/dto/oauth/LoginStatus.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/oauth/LoginStatus.java diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/LoginStatus.java b/src/main/java/com/scriptopia/demo/dto/oauth/LoginStatus.java new file mode 100644 index 00000000..d7905e04 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/oauth/LoginStatus.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.dto.oauth; + +public enum LoginStatus { + LOGIN_SUCCESS, SIGNUP_REQUIRED +} From 4ce834c6c72296e8eb7402ecb787f75c858b8415 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:36:44 +0900 Subject: [PATCH 166/527] feat: add dto OAuthLoginResponse for social login --- .../demo/dto/oauth/OAuthLoginResponse.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java new file mode 100644 index 00000000..21a02d7d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java @@ -0,0 +1,20 @@ +package com.scriptopia.demo.dto.oauth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OAuthLoginResponse { + private LoginStatus status; // LOGIN_SUCCESS or SIGNUP_REQUIRED + private String accessToken; + private String refreshToken; + + // 회원가입 필요할 때만 내려줌 + private String socialId; + private String email; + private String provider; +} + From fd32b74b8fd5613b7ea47d28966bb29f76763f0f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 18:41:26 +0900 Subject: [PATCH 167/527] feat: create RestTemplateConfig to configuration --- .../demo/config/RestTemplateConfig.java | 15 ++++++++++ .../demo/service/GameSessionService.java | 28 +++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/config/RestTemplateConfig.java diff --git a/src/main/java/com/scriptopia/demo/config/RestTemplateConfig.java b/src/main/java/com/scriptopia/demo/config/RestTemplateConfig.java new file mode 100644 index 00000000..d4647fd7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } +} diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 8253586e..482eb24e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -2,27 +2,27 @@ import com.scriptopia.demo.domain.GameSession; import com.scriptopia.demo.domain.User; -import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; -import com.scriptopia.demo.dto.gamesession.GameSessionRequest; -import com.scriptopia.demo.dto.gamesession.GameSessionResponse; -import com.scriptopia.demo.dto.gamesession.StartGameRequest; +import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; @Service @RequiredArgsConstructor public class GameSessionService { private final GameSessionRepository gameSessionRepository; private final UserRepository userRepository; + private final RestTemplateBuilder restTemplateBuilder; + private final RestTemplateAutoConfiguration restTemplateAutoConfiguration; + private final RestTemplate restTemplate; public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) @@ -70,12 +70,24 @@ public String startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); } + + CreateGameRequest createGameRequest = new CreateGameRequest( + request.getBackground(), + request.getCharacterName(), + request.getCharacterDescription() + ); + // 2. FastAPI 호출(테스트용 추후 변경 가능) String url = "http://localhost:8000/games/init"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(createGameRequest, headers); + + ResponseEntity responseEntity = + restTemplateBuilder.build(url, HttpMethod.POST, requestEntity, ExternalGameResponse.class); + String requestBody = String.format( "{\"background\":\"%s\",\"characterName\":\"%s\",\"characterDescription\":\"%s\"}", request From 6526d075993e82b13fd62a43f0f8f4124e573e20 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:41:56 +0900 Subject: [PATCH 168/527] feat: update local user register method add LoginType in user entity --- .../java/com/scriptopia/demo/service/LocalAccountService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index a36b4c13..02b77777 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -150,6 +150,7 @@ public void register(RegisterRequest request) { user.setLastLoginAt(null); user.setProfileImgUrl(null); user.setRole(Role.USER); + user.setLoginType(LoginType.LOCAL); userRepository.save(user); //localAccount 객체 생성 From 189671c3caf61976fabcfd7d36a7ccb5f0319cdf Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:44:08 +0900 Subject: [PATCH 169/527] feat: update social signup request dto add device Id --- .../java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java index fa7fce0f..7998f15d 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java @@ -14,4 +14,5 @@ public class SocialSignupRequest { private String socialId; private String email; private String nickname; + private String deviceId; } From b671ad7e5fa3d2b2f3b117c7655b126adfb3e304 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 18:45:11 +0900 Subject: [PATCH 170/527] feat: create E_500_EXTERNAL_API_ERROR --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 4 +++- .../scriptopia/demo/service/GameSessionService.java | 11 ----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 66f2d473..b7b55ad1 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -74,7 +74,9 @@ public enum ErrorCode { //500 Internal Server Error E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - E_500_TOKEN_HASHING_FAILED("E_500001","리프레쉬 토큰 해싱에 실패했습니다.",HttpStatus.INTERNAL_SERVER_ERROR); + E_500_TOKEN_HASHING_FAILED("E_500001","리프레쉬 토큰 해싱에 실패했습니다.",HttpStatus.INTERNAL_SERVER_ERROR), + E_500_EXTERNAL_API_ERROR("E500001", "외부 게임 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + private final String code; private final String message; diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 482eb24e..c53877d4 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -21,7 +21,6 @@ public class GameSessionService { private final GameSessionRepository gameSessionRepository; private final UserRepository userRepository; private final RestTemplateBuilder restTemplateBuilder; - private final RestTemplateAutoConfiguration restTemplateAutoConfiguration; private final RestTemplate restTemplate; public ResponseEntity getGameSession(Long userid) { @@ -85,16 +84,6 @@ public String startNewGame(Long userId, StartGameRequest request) { HttpEntity requestEntity = new HttpEntity<>(createGameRequest, headers); - ResponseEntity responseEntity = - restTemplateBuilder.build(url, HttpMethod.POST, requestEntity, ExternalGameResponse.class); - - String requestBody = String.format( - "{\"background\":\"%s\",\"characterName\":\"%s\",\"characterDescription\":\"%s\"}", - request - ); - - HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); - ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, ExternalGameResponse.class); From 0a2c240526e029bb655de438e97036d75820f53d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 31 Aug 2025 18:50:03 +0900 Subject: [PATCH 171/527] feature/sharedGameController shared game delete error add --- .../demo/controller/SharedGameController.java | 4 +++- .../demo/dto/sharedgame/MySharedGameResponse.java | 1 + .../java/com/scriptopia/demo/exception/ErrorCode.java | 1 + .../demo/repository/SharedGameFavoriteRepository.java | 4 ++++ .../demo/repository/SharedGameRepository.java | 1 + .../com/scriptopia/demo/service/SharedGameService.java | 10 +++++++--- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index a3398dd9..6032aa4d 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -27,9 +27,11 @@ public ResponseEntity getMySharedGames(Authentication authentication) { } @DeleteMapping("/share/{gameid}") - public void delete(Authentication authentication, @PathVariable Long gameid) { + public ResponseEntity delete(Authentication authentication, @PathVariable Long gameid) { Long userId = Long.valueOf(authentication.getName()); sharedGameService.deletesharedGame(userId, gameid); + + return ResponseEntity.ok("게임이 삭제되었습니다."); } } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java index 5e2c6a2d..e16cce7e 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java @@ -8,6 +8,7 @@ @Data public class MySharedGameResponse { private String thumbnailUrl; + private boolean recommand; private Long totalPlayed; private String title; private String worldView; diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index cdb3d841..ee370836 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -44,6 +44,7 @@ public enum ErrorCode { E_401_MALFORMED("E401006", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), E_401_EXPIRED_JWT("E401007", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), E_401_UNSUPPORTED_JWT("E401008", "지원하지 않는 JWT 형식입니다.", HttpStatus.UNAUTHORIZED), + E_401_NOT_EQUAL_SHARED_GAME("E401009", "사용자가 공유한 게임이 아닙니다.", HttpStatus.UNAUTHORIZED), //403 Forbidden E_403("E403000", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java index 9c6d0301..2b7af374 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java @@ -8,7 +8,11 @@ public interface SharedGameFavoriteRepository extends JpaRepository { Optional findByUserIdAndSharedGameId(Long userId, Long sharedGameId); + boolean existsByUserIdAndSharedGameId(Long userId, Long sharedGameId); + long countBySharedGameId(Long sharedGameId); + void deleteByUserIdAndSharedGameId(Long userId, Long sharedGameId); + } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index 5ad86374..ce2f7177 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -8,4 +8,5 @@ public interface SharedGameRepository extends JpaRepository { List findAllByUserId(Long userId); + } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 2564fb95..5ad5a87b 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -21,6 +21,7 @@ public class SharedGameService { private final HistoryRepository historyRepository; private final UserRepository userRepository; private final SharedGameScoreRepository sharedGameScoreRepository; + private final SharedGameFavoriteRepository sharedGameFavoriteRepository; private final GameTagRepository gameTagRepository; @Transactional @@ -54,6 +55,9 @@ public ResponseEntity getMySharedGames(Long userId) { dto.setBackgroundStory(game.getBackgroundStory()); dto.setSharedAt(game.getSharedAt()); + boolean liked = sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(user.getId(), game.getId()); + dto.setRecommand(liked); + List names = gameTagRepository.findTagNamesBySharedGameId(game.getId()); dto.setTags( names.stream() @@ -69,13 +73,13 @@ public ResponseEntity getMySharedGames(Long userId) { @Transactional public void deletesharedGame(Long id, Long sharedId) { User user = userRepository.findById(id) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); SharedGame game = sharedGameRepository.findById(sharedId) - .orElseThrow(() -> new RuntimeException("Shared game not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); if(!game.getUser().getId().equals(user.getId())) { // 공유된 게임과 로그인한 사용자가 아닌 경우 - throw new RuntimeException("User not your history"); + throw new CustomException(ErrorCode.E_401_NOT_EQUAL_SHARED_GAME); } sharedGameRepository.delete(game); From aafe54ec42cc0339c977076fea9ee265c6bc6fd6 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:51:32 +0900 Subject: [PATCH 172/527] feat: create method findBySocialIdAndProvider for social login --- .../demo/repository/SocialAccountRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java index e859da4d..1ba10390 100644 --- a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java @@ -1,8 +1,15 @@ package com.scriptopia.demo.repository; +import com.scriptopia.demo.domain.Provider; import com.scriptopia.demo.domain.SharedGameScore; import com.scriptopia.demo.domain.SocialAccount; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface SocialAccountRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findBySocialIdAndProvider(String id, Provider provider); } From e51a6f77dda6d46d231f0c8d664ff563c112605a Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:52:55 +0900 Subject: [PATCH 173/527] feat: add dto OAuthUserInfo for get user social info --- .../scriptopia/demo/dto/oauth/OAuthUserInfo.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java new file mode 100644 index 00000000..4c62695d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.oauth; + +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OAuthUserInfo { + private String id; + private String email; + private String name; + private String profileImage; +} From ceb5ea3dea2904e3b371a05bfb95d4839abb3c15 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 18:53:19 +0900 Subject: [PATCH 174/527] feat: add dto OAuthLoginResponse --- .../java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java index 21a02d7d..ac882dea 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java @@ -1,10 +1,12 @@ package com.scriptopia.demo.dto.oauth; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data +@Builder @AllArgsConstructor @NoArgsConstructor public class OAuthLoginResponse { From 2d5d6783268a608e13e47ac97e27e4118ee8a794 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 19:02:42 +0900 Subject: [PATCH 175/527] feat: create GameBalanceUtil to utils --- .../demo/service/GameSessionService.java | 15 ++-- .../demo/utils/GameBalanceUtil.java | 72 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index c53877d4..34a1b4f9 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -93,10 +93,17 @@ public String startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } - // 3. 밸런스 재세팅 (여기서 필요한 값 수정 가능) - // 예: life, gold 등 초기화 - externalGame.getPlayer_info().setLife(100); - externalGame.getPlayer_info().setGold(100); + // 3. 밸런스 재세팅 (여기서 필요한 값 수정) + externalGame.getPlayer_info().setLife(5); + externalGame.getPlayer_info().setLevel(1); + externalGame.getPlayer_info().setExperience_point(0); + + + + + + + // 4. MongoDB 저장 GameSessionMongo mongoSession = new GameSessionMongo(); diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java new file mode 100644 index 00000000..be456c9c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -0,0 +1,72 @@ +package com.scriptopia.demo.utils; + +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.ItemDef; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.ItemDef.ItemEffect; + +import java.util.List; + +public class GameBalanceUtil { + + // 효과 등급별 공격력 증가 배율 + private static double getEffectGradeMultiplier(String grade) { + return switch (grade) { + case "C" -> 0.1; + case "U" -> 0.15; + case "R" -> 0.2; + case "E" -> 0.25; + case "L" -> 0.3; + default -> 0.0; + }; + } + + // 주 스탯 구간별 배율 + private static double getMainStatMultiplier(int statValue) { + if (statValue <= 5) return 1.1; + if (statValue <= 10) return 1.2; + if (statValue <= 15) return 1.3; + if (statValue <= 20) return 1.4; + if (statValue <= 25) return 1.5; + if (statValue <= 30) return 1.6; + if (statValue <= 35) return 1.7; + if (statValue <= 40) return 1.8; + if (statValue <= 45) return 1.9; + return 2.0; + } + + // 캐릭터 스탯 + 장착 아이템 스탯 합산 + public static void applyItemStatsAndCombatPoint(ExternalGameResponse game) { + ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); + List items = game.getItem_def(); + + int combatPoint = 0; + + for (ItemDef item : items) { + // 기본 스탯 합산 + player.setStrength(player.getStrength() + item.getStrength()); + player.setAgility(player.getAgility() + item.getAgility()); + player.setIntelligence(player.getIntelligence() + item.getIntelligence()); + player.setLuck(player.getLuck() + item.getLuck()); + + // 무기라면 combat_point 계산 + if ("WEAPON".equals(item.getCategory())) { + double effectMultiplier = item.getItem_effect().stream() + .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) + .sum(); + + int mainStatValue = switch (item.getMain_stat()) { + case "strength" -> player.getStrength(); + case "agility" -> player.getAgility(); + case "intelligence" -> player.getIntelligence(); + case "luck" -> player.getLuck(); + default -> 0; + }; + + double mainStatMultiplier = getMainStatMultiplier(mainStatValue); + combatPoint += (int) (item.getBase_stat() * (1 + effectMultiplier) * mainStatMultiplier); + } + } + + player.setCombat_point(combatPoint); + } +} \ No newline at end of file From a983da199ed3bd3b4cb0cc5203d3b9dac223fc51 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 19:31:07 +0900 Subject: [PATCH 176/527] feat: create applyEquippedArmorStatsAndHealthPoint refactor: applyEquippedWeaponStatsAndCombatPoint --- .../dto/gamesession/ExternalGameResponse.java | 11 +- .../demo/service/GameSessionService.java | 14 +- .../demo/utils/GameBalanceUtil.java | 160 ++++++++++++------ 3 files changed, 128 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 81dacefb..89af06b5 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -1,5 +1,8 @@ package com.scriptopia.demo.dto.gamesession; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.MainStat; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -53,16 +56,16 @@ public static class ItemDef { private String item_pic_src; private String name; private String description; - private String category; + private ItemType category; private int base_stat; private List item_effect; private int strength; private int agility; private int intelligence; private int luck; - private String main_stat; + private MainStat main_stat; private int weight; - private String grade; + private Grade grade; private int price; @Data @@ -71,7 +74,7 @@ public static class ItemDef { public static class ItemEffect { private String item_effect_name; private String item_effect_description; - private String grade; + private Grade grade; private int item_effect_weight; } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 34a1b4f9..cd22aa19 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.utils.GameBalanceUtil; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; import org.springframework.boot.web.client.RestTemplateBuilder; @@ -93,13 +94,18 @@ public String startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } - // 3. 밸런스 재세팅 (여기서 필요한 값 수정) - externalGame.getPlayer_info().setLife(5); - externalGame.getPlayer_info().setLevel(1); - externalGame.getPlayer_info().setExperience_point(0); + // 3. 밸런스 재세팅 + ExternalGameResponse.PlayerInfo player = externalGame.getPlayer_info(); + player.setLife(5); + player.setLevel(1); + player.setExperience_point(0); + // 4. 아이템 적용 및 전투력 계산 + GameBalanceUtil.applyEquippedWeaponStatsAndCombatPoint(externalGame); + GameBalanceUtil.applyEquippedArmorStatsAndHealthPoint(externalGame); + diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index be456c9c..7238eb9b 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -1,26 +1,124 @@ package com.scriptopia.demo.utils; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.MainStat; import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; -import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.ItemDef; -import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.ItemDef.ItemEffect; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; public class GameBalanceUtil { - // 효과 등급별 공격력 증가 배율 - private static double getEffectGradeMultiplier(String grade) { + // 착용 무기 적용 후 캐릭터 스탯 및 combat_point 계산 + public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse game) { + ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); + List itemDefs = game.getItem_def(); + List inventory = game.getInventory(); + + // item_def_id 기준 Map 생성 + Map itemDefMap = itemDefs.stream() + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + + // 착용 중인 무기 하나 찾기 + ExternalGameResponse.ItemDef equippedWeapon = inventory.stream() + .filter(ExternalGameResponse.InventoryItem::isEquipped) + .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .filter(item -> item != null && item.getCategory() == ItemType.WEAPON) + .findFirst() + .orElse(null); + + if (equippedWeapon != null) { + // 1. 무기 스탯 합산 + player.setStrength(player.getStrength() + equippedWeapon.getStrength()); + player.setAgility(player.getAgility() + equippedWeapon.getAgility()); + player.setIntelligence(player.getIntelligence() + equippedWeapon.getIntelligence()); + player.setLuck(player.getLuck() + equippedWeapon.getLuck()); + + // 2. combat_point 계산 + double effectMultiplier = equippedWeapon.getItem_effect().stream() + .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) + .sum(); + + int mainStatValue = switch (equippedWeapon.getMain_stat()) { + case STRENGTH -> player.getStrength(); + case AGILITY -> player.getAgility(); + case INTELLIGENCE -> player.getIntelligence(); + case LUCK -> player.getLuck(); + default -> 0; + }; + + double mainStatMultiplier = getMainStatMultiplier(mainStatValue); + + int combatPoint = (int) (equippedWeapon.getBase_stat() * (1 + effectMultiplier) * mainStatMultiplier); + + player.setCombat_point(combatPoint); + } else { + // 무기 미착용 시: 스탯 합 * 가장 높은 스탯 배율 + int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); + int maxStat = Math.max(Math.max(player.getStrength(), player.getAgility()), + Math.max(player.getIntelligence(), player.getLuck())); + double mainStatMultiplier = getMainStatMultiplier(maxStat); + int combatPoint = (int) (totalStat * mainStatMultiplier); + player.setCombat_point(combatPoint); + } + } + + // 착용 방어구 적용 후 캐릭터 스탯 및 health_point 계산 + public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse game) { + ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); + List itemDefs = game.getItem_def(); + List inventory = game.getInventory(); + + // item_def_id 기준 Map 생성 + Map itemDefMap = itemDefs.stream() + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + + // 착용 중인 방어구 하나 찾기 + ExternalGameResponse.ItemDef equippedArmor = inventory.stream() + .filter(ExternalGameResponse.InventoryItem::isEquipped) + .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .filter(item -> item != null && item.getCategory() == ItemType.ARMOR) + .findFirst() + .orElse(null); + + if (equippedArmor != null) { + // 1. 방어구 스탯 합산 + player.setStrength(player.getStrength() + equippedArmor.getStrength()); + player.setAgility(player.getAgility() + equippedArmor.getAgility()); + player.setIntelligence(player.getIntelligence() + equippedArmor.getIntelligence()); + player.setLuck(player.getLuck() + equippedArmor.getLuck()); + + // 2. health_point 계산 + double effectMultiplier = equippedArmor.getItem_effect().stream() + .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) + .sum(); + + int baseHealth = equippedArmor.getBase_stat(); + int healthPoint = (int) (baseHealth * (1 + effectMultiplier)); + player.setHealth_point(healthPoint); + } else { + // 방어구 미착용 시: 전체 스탯 합 * 3 + int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); + int healthPoint = totalStat * 3; + player.setHealth_point(healthPoint); + } + } + + + // 아이템 효과 등급 배율 + private static double getEffectGradeMultiplier(Grade grade) { return switch (grade) { - case "C" -> 0.1; - case "U" -> 0.15; - case "R" -> 0.2; - case "E" -> 0.25; - case "L" -> 0.3; - default -> 0.0; + case COMMON -> 0.1; + case UNCOMMON -> 0.15; + case RARE -> 0.2; + case EPIC -> 0.25; + case LEGENDARY -> 0.3; }; } - // 주 스탯 구간별 배율 + // 메인 스탯 값 구간 배율 private static double getMainStatMultiplier(int statValue) { if (statValue <= 5) return 1.1; if (statValue <= 10) return 1.2; @@ -31,42 +129,6 @@ private static double getMainStatMultiplier(int statValue) { if (statValue <= 35) return 1.7; if (statValue <= 40) return 1.8; if (statValue <= 45) return 1.9; - return 2.0; - } - - // 캐릭터 스탯 + 장착 아이템 스탯 합산 - public static void applyItemStatsAndCombatPoint(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); - List items = game.getItem_def(); - - int combatPoint = 0; - - for (ItemDef item : items) { - // 기본 스탯 합산 - player.setStrength(player.getStrength() + item.getStrength()); - player.setAgility(player.getAgility() + item.getAgility()); - player.setIntelligence(player.getIntelligence() + item.getIntelligence()); - player.setLuck(player.getLuck() + item.getLuck()); - - // 무기라면 combat_point 계산 - if ("WEAPON".equals(item.getCategory())) { - double effectMultiplier = item.getItem_effect().stream() - .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) - .sum(); - - int mainStatValue = switch (item.getMain_stat()) { - case "strength" -> player.getStrength(); - case "agility" -> player.getAgility(); - case "intelligence" -> player.getIntelligence(); - case "luck" -> player.getLuck(); - default -> 0; - }; - - double mainStatMultiplier = getMainStatMultiplier(mainStatValue); - combatPoint += (int) (item.getBase_stat() * (1 + effectMultiplier) * mainStatMultiplier); - } - } - - player.setCombat_point(combatPoint); + return 2.0; // 46 이상 } -} \ No newline at end of file +} From a0b9ec9ff1fe7378674a7bca57663884abdef015 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 19:36:52 +0900 Subject: [PATCH 177/527] feat: create applyEquippedArtifactStats --- .../demo/service/GameSessionService.java | 5 +--- .../demo/utils/GameBalanceUtil.java | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index cd22aa19..2e1be4e2 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -102,11 +102,8 @@ public String startNewGame(Long userId, StartGameRequest request) { // 4. 아이템 적용 및 전투력 계산 GameBalanceUtil.applyEquippedWeaponStatsAndCombatPoint(externalGame); - - GameBalanceUtil.applyEquippedArmorStatsAndHealthPoint(externalGame); - - + GameBalanceUtil.applyEquippedArtifactStats(externalGame); diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 7238eb9b..fffef146 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -107,6 +107,34 @@ public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse ga } + // 착용 아티팩트 적용 후 캐릭터 스탯 계산 (전투력/체력 보정은 추후) + public static void applyEquippedArtifactStats(ExternalGameResponse game) { + ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); + List itemDefs = game.getItem_def(); + List inventory = game.getInventory(); + + // item_def_id 기준 Map 생성 + Map itemDefMap = itemDefs.stream() + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + + // 착용 중인 아티팩트 하나 찾기 + ExternalGameResponse.ItemDef equippedArtifact = inventory.stream() + .filter(ExternalGameResponse.InventoryItem::isEquipped) + .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .filter(item -> item != null && item.getCategory() == ItemType.ARTIFACT) + .findFirst() + .orElse(null); + + if (equippedArtifact != null) { + // 아티팩트 스탯 합산 + player.setStrength(player.getStrength() + equippedArtifact.getStrength()); + player.setAgility(player.getAgility() + equippedArtifact.getAgility()); + player.setIntelligence(player.getIntelligence() + equippedArtifact.getIntelligence()); + player.setLuck(player.getLuck() + equippedArtifact.getLuck()); + } + } + + // 아이템 효과 등급 배율 private static double getEffectGradeMultiplier(Grade grade) { return switch (grade) { From b399db2b2ed65923eef34da4240584470a88634e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 19:49:42 +0900 Subject: [PATCH 178/527] =?UTF-8?q?refactor:=20add=20@=C3=A3NOCONST,=20@AL?= =?UTF-8?q?LCONST=20to=20domain/mongo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/mongo/BattleInfoMongo.java | 4 +++ .../demo/domain/mongo/BattleTurnMongo.java | 4 +++ .../demo/domain/mongo/ChoiceInfoMongo.java | 4 +++ .../demo/domain/mongo/ChoiceMongo.java | 4 +++ .../demo/domain/mongo/DoneInfoMongo.java | 4 +++ .../demo/domain/mongo/GameSessionMongo.java | 4 +++ .../demo/domain/mongo/HistoryInfoMongo.java | 4 +++ .../demo/domain/mongo/InventoryItemMongo.java | 8 +++-- .../demo/domain/mongo/ItemDefMongo.java | 4 +++ .../demo/domain/mongo/ItemEffectMongo.java | 4 +++ .../demo/domain/mongo/PlayerInfoMongo.java | 4 +++ .../demo/domain/mongo/RewardInfoMongo.java | 4 +++ .../demo/domain/mongo/ShopInfoMongo.java | 4 +++ .../demo/service/GameSessionService.java | 32 +++++++++++++------ 14 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java index b0c14daf..150fbad5 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java @@ -1,12 +1,16 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.List; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class BattleInfoMongo { private Long curTurnId; private List playerHp; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java index df435b65..17e1a567 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java @@ -1,10 +1,14 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class BattleTurnMongo { private Integer turnId; private String turnInfo; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java index 2ba73a38..64cd7d05 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java @@ -1,13 +1,17 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.ChoiceEventType; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.List; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class ChoiceInfoMongo { private ChoiceEventType eventType; // living, nonliving private String story; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index 135f779b..7ba59ede 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -2,12 +2,16 @@ import com.scriptopia.demo.domain.ChoiceResultType; import com.scriptopia.demo.domain.MainStat; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class ChoiceMongo { private String detail; private MainStat stats; // strength, agility, intelligence, luck diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java index 59d8a939..8926ce0a 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java @@ -1,10 +1,14 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class DoneInfoMongo { private String story; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 77693413..fba86901 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -1,7 +1,9 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.SceneType; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -12,6 +14,8 @@ @Getter @Setter @Document(collection = "game_sessions") +@AllArgsConstructor +@NoArgsConstructor public class GameSessionMongo { @Id diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java index 80f5cf76..fdd92af0 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java @@ -1,10 +1,14 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class HistoryInfoMongo { private String title; private String worldView; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java index 4eb03b84..e4fc2d56 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java @@ -1,15 +1,19 @@ package com.scriptopia.demo.domain.mongo; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.time.LocalDateTime; @Getter @Setter +@NoArgsConstructor +@AllArgsConstructor public class InventoryItemMongo { private Long itemDefId; private LocalDateTime acquiredAt; private Boolean equipped; private String source; + + public InventoryItemMongo(int itemDefId, String acquiredAt, boolean equipped, String source) { + } } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index e15c40f3..baf852a3 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -3,13 +3,17 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemCategory; import com.scriptopia.demo.domain.MainStat; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.List; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class ItemDefMongo { private Long itemDefId; private String itemPicSrc; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java index 4f5bea06..014206cf 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java @@ -1,11 +1,15 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.Grade; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class ItemEffectMongo { private String itemEffectName; private String itemEffectDescription; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java index f4311bf9..35964398 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java @@ -1,10 +1,14 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class PlayerInfoMongo { private String name; private Integer life; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java index c919e2b1..16b8970e 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java @@ -1,12 +1,16 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.List; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class RewardInfoMongo { private List gainedItemDefId; private List lostItemsDefId; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java index 97efafbf..b6ffdd4f 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java @@ -1,12 +1,16 @@ package com.scriptopia.demo.domain.mongo; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.List; @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor public class ShopInfoMongo { private List itemDefId; } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2e1be4e2..2043e1f9 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,7 +1,9 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.GameSession; -import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.domain.mongo.ChoiceInfoMongo; +import com.scriptopia.demo.domain.mongo.GameSessionMongo; +import com.scriptopia.demo.domain.mongo.InventoryItemMongo; import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; @@ -16,6 +18,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; +import java.util.List; + @Service @RequiredArgsConstructor public class GameSessionService { @@ -106,20 +111,27 @@ public String startNewGame(Long userId, StartGameRequest request) { GameBalanceUtil.applyEquippedArtifactStats(externalGame); - - - // 4. MongoDB 저장 + // 5. MongoDB 저장 GameSessionMongo mongoSession = new GameSessionMongo(); mongoSession.setUserId(userId); - mongoSession.setSceneType("choice"); // 시작은 choice 등 기본값 + mongoSession.setSceneType(SceneType.CHOICE); // 시작은 choice 기본값 mongoSession.setStartedAt(LocalDateTime.now()); mongoSession.setUpdatedAt(LocalDateTime.now()); - mongoSession.setBackground(background); - mongoSession.setPlayerInfo(convertPlayerInfo(externalGame)); - mongoSession.setInventory(convertInventory(externalGame)); - mongoSession.setItemDef(convertItemDef(externalGame)); + mongoSession.setBackground(request.getBackground()); mongoSession.setProgress(0); + + List mongoInventory = externalGame.getInventory().stream() + .map(inv -> new InventoryItemMongo( + inv.getItem_def_id(), + inv.getAcquired_at(), + inv.isEquipped(), + inv.getSource() + )) + .toList(); + mongoSession.setInventory(mongoInventory); + + GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); // 5. MySQL GameSession에 MongoDB PK 저장 From 2b28b83654842b4efe7c5d2be65c7ba5c9ba3a50 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 20:06:26 +0900 Subject: [PATCH 179/527] feat: createGameSession logic create StartGameResponse --- .../controller/GameSessionController.java | 2 +- .../com/scriptopia/demo/domain/ItemType.java | 3 +- .../demo/domain/mongo/ItemDefMongo.java | 3 +- .../dto/gamesession/ExternalGameResponse.java | 2 +- .../dto/gamesession/StartGameResponse.java | 6 + .../demo/service/GameSessionService.java | 155 +++++++++--------- 6 files changed, 94 insertions(+), 77 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 9b599c1c..e6abe5c8 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -41,7 +41,7 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV // 게임 시작 @PostMapping - public ResponseEntity startNewGame( + public ResponseEntity startNewGame( @RequestBody StartGameRequest request, Authentication authentication) { diff --git a/src/main/java/com/scriptopia/demo/domain/ItemType.java b/src/main/java/com/scriptopia/demo/domain/ItemType.java index ab475770..bc8b0524 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemType.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemType.java @@ -3,5 +3,6 @@ public enum ItemType { WEAPON, ARMOR, - ARTIFACT + ARTIFACT, + POTION } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index baf852a3..a8363af0 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemCategory; +import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.MainStat; import lombok.AllArgsConstructor; import lombok.Getter; @@ -19,7 +20,7 @@ public class ItemDefMongo { private String itemPicSrc; private String name; private String description; - private ItemCategory category; // WEAPON, ARMOR, ARTIFACT, POTION + private ItemType category; // WEAPON, ARMOR, ARTIFACT, *POTION* 타입 때문에 애매함 private Integer baseStat; private List itemEffect; private Integer strength; diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 89af06b5..1f432151 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -52,7 +52,7 @@ public static class InventoryItem { @AllArgsConstructor @NoArgsConstructor public static class ItemDef { - private int item_def_id; + private Long item_def_id; private String item_pic_src; private String name; private String description; diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java new file mode 100644 index 00000000..56483926 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java @@ -0,0 +1,6 @@ +package com.scriptopia.demo.dto.gamesession; + +public class StartGameResponse { + private String message; + private String gameId; +} diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2043e1f9..e9248400 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,12 +1,11 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.domain.mongo.ChoiceInfoMongo; -import com.scriptopia.demo.domain.mongo.GameSessionMongo; -import com.scriptopia.demo.domain.mongo.InventoryItemMongo; +import com.scriptopia.demo.domain.mongo.*; import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.GameSessionMongoRepository; import com.scriptopia.demo.repository.GameSessionRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.GameBalanceUtil; @@ -19,7 +18,9 @@ import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.Random; @Service @RequiredArgsConstructor @@ -28,6 +29,7 @@ public class GameSessionService { private final UserRepository userRepository; private final RestTemplateBuilder restTemplateBuilder; private final RestTemplate restTemplate; + private final GameSessionMongoRepository gameSessionMongoRepository; public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) @@ -68,6 +70,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { } + @Transactional public String startNewGame(Long userId, StartGameRequest request) { // 1. 진행중인 게임 체크 @@ -121,6 +124,7 @@ public String startNewGame(Long userId, StartGameRequest request) { mongoSession.setProgress(0); + // 아이템 정보 List mongoInventory = externalGame.getInventory().stream() .map(inv -> new InventoryItemMongo( inv.getItem_def_id(), @@ -131,87 +135,92 @@ public String startNewGame(Long userId, StartGameRequest request) { .toList(); mongoSession.setInventory(mongoInventory); + // ItemDef + List mongoItemDefs = externalGame.getItem_def().stream() + .map(item -> new ItemDefMongo( + item.getItem_def_id(), + item.getItem_pic_src(), + item.getName(), + item.getDescription(), + item.getCategory(), + item.getBase_stat(), + item.getItem_effect().stream() + .map(e -> new ItemEffectMongo( + e.getItem_effect_name(), + e.getItem_effect_description(), + e.getGrade(), + e.getItem_effect_weight() + )) + .toList(), + item.getStrength(), + item.getAgility(), + item.getIntelligence(), + item.getLuck(), + item.getMain_stat(), + item.getWeight(), + item.getGrade(), + item.getPrice() + )) + .toList(); + mongoSession.setItemDef(mongoItemDefs); + + // 플레이어 정보 + ExternalGameResponse.PlayerInfo p = externalGame.getPlayer_info(); + PlayerInfoMongo playerMongo = new PlayerInfoMongo( + p.getName(), + p.getLife(), + p.getLevel(), + p.getExperience_point(), + p.getCombat_point(), + p.getHealth_point(), + p.getTrait(), + p.getStrength(), + p.getAgility(), + p.getIntelligence(), + p.getLuck(), + p.getGold() + ); + mongoSession.setPlayerInfo(playerMongo); + + // 초기 히스토리 저장 + HistoryInfoMongo history = new HistoryInfoMongo(); + history.setWorldView(externalGame.getWorld_view()); + history.setBackgroundStory(externalGame.getBackground_story()); + mongoSession.setHistoryInfo(history); + + + int stageCount = 10; // 예: 10스테이지 + List stageList = new ArrayList<>(); + Random random = new Random(); + for (int i = 0; i < stageCount; i++) { + // 0: 작은 이벤트, 1: 큰 이벤트 + stageList.add(random.nextInt(2)); + } + mongoSession.setStage(stageList); + + // 게임 진행 시 필요한 것들은 만들어만 두기 + mongoSession.setChoiceInfo(new ChoiceInfoMongo()); + mongoSession.setDoneInfo(new DoneInfoMongo()); + mongoSession.setShopInfo(new ShopInfoMongo()); + mongoSession.setBattleInfo(new BattleInfoMongo()); + mongoSession.setRewardInfo(new RewardInfoMongo()); + mongoSession.setHistoryInfo(new HistoryInfoMongo()); GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); // 5. MySQL GameSession에 MongoDB PK 저장 + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + GameSession mysqlSession = new GameSession(); - mysqlSession.setUserId(userId); + mysqlSession.setUser(user); mysqlSession.setMongoId(savedMongo.getId()); - mysqlSession.setSceneType("choice"); - mysqlSession.setStartedAt(LocalDateTime.now()); - mysqlSession.setUpdatedAt(LocalDateTime.now()); gameSessionRepository.save(mysqlSession); // 6. MongoDB PK 반환 return savedMongo.getId(); } - private GameSessionMongo.PlayerInfoMongo convertPlayerInfo(ExternalGameResponse external) { - GameSessionMongo.PlayerInfoMongo info = new GameSessionMongo.PlayerInfoMongo(); - ExternalGameResponse.PlayerInfo p = external.getPlayer_info(); - info.setName(p.getName()); - info.setLife(p.getLife()); - info.setLevel(p.getLevel()); - info.setExperiencePoint(p.getExperience_point()); - info.setCombatPoint(p.getCombat_point()); - info.setHealthPoint(p.getHealth_point()); - info.setTrait(p.getTrait()); - info.setStrength(p.getStrength()); - info.setAgility(p.getAgility()); - info.setIntelligence(p.getIntelligence()); - info.setLuck(p.getLuck()); - info.setGold(p.getGold()); - return info; - } - - private java.util.List convertInventory(ExternalGameResponse external) { - java.util.List list = new java.util.ArrayList<>(); - for (ExternalGameResponse.InventoryItem item : external.getInventory()) { - GameSessionMongo.InventoryItemMongo mongoItem = new GameSessionMongo.InventoryItemMongo(); - mongoItem.setItemDefId(item.getItem_def_id()); - mongoItem.setAcquiredAt(LocalDateTime.parse(item.getAcquired_at())); - mongoItem.setEquipped(item.isEquipped()); - mongoItem.setSource(item.getSource()); - list.add(mongoItem); - } - return list; - } - - private java.util.List convertItemDef(ExternalGameResponse external) { - java.util.List list = new java.util.ArrayList<>(); - for (ExternalGameResponse.ItemDef def : external.getItem_def()) { - GameSessionMongo.ItemDefMongo mongoDef = new GameSessionMongo.ItemDefMongo(); - mongoDef.setItemDefId(def.getItem_def_id()); - mongoDef.setItemPicSrc(def.getItem_pic_src()); - mongoDef.setName(def.getName()); - mongoDef.setDescription(def.getDescription()); - mongoDef.setCategory(def.getCategory()); - mongoDef.setBaseStat(def.getBase_stat()); - mongoDef.setStrength(def.getStrength()); - mongoDef.setAgility(def.getAgility()); - mongoDef.setIntelligence(def.getIntelligence()); - mongoDef.setLuck(def.getLuck()); - mongoDef.setMainStat(def.getMain_stat()); - mongoDef.setWeight(def.getWeight()); - mongoDef.setGrade(def.getGrade()); - mongoDef.setPrice(def.getPrice()); - - java.util.List effectList = new java.util.ArrayList<>(); - for (ExternalGameResponse.ItemDef.ItemEffect eff : def.getItem_effect()) { - GameSessionMongo.ItemDefMongo.ItemEffectMongo effMongo = new GameSessionMongo.ItemDefMongo.ItemEffectMongo(); - effMongo.setItemEffectName(eff.getItem_effect_name()); - effMongo.setItemEffectDescription(eff.getItem_effect_description()); - effMongo.setGrade(eff.getGrade()); - effMongo.setItemEffectWeight(eff.getItem_effect_weight()); - effectList.add(effMongo); - } - mongoDef.setItemEffect(effectList); - - list.add(mongoDef); - } - return list; - } - } From 4db629a53cc5ce6400cf260b1d6a6214c1fddf7a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 20:09:13 +0900 Subject: [PATCH 180/527] before test --- .../demo/controller/GameSessionController.java | 5 +++-- .../demo/dto/gamesession/StartGameResponse.java | 7 +++++++ .../scriptopia/demo/service/GameSessionService.java | 10 ++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index e6abe5c8..ebc16e84 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.dto.gamesession.GameSessionRequest; import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.dto.gamesession.StartGameRequest; +import com.scriptopia.demo.dto.gamesession.StartGameResponse; import com.scriptopia.demo.service.GameSessionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -41,12 +42,12 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV // 게임 시작 @PostMapping - public ResponseEntity startNewGame( + public ResponseEntity startNewGame( @RequestBody StartGameRequest request, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - StartGameRequest response = gameSessionService.startNewGame(userId, request); + StartGameResponse response = gameSessionService.startNewGame(userId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java index 56483926..c2dfd654 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/StartGameResponse.java @@ -1,5 +1,12 @@ package com.scriptopia.demo.dto.gamesession; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor public class StartGameResponse { private String message; private String gameId; diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index e9248400..65be536c 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -71,7 +71,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { @Transactional - public String startNewGame(Long userId, StartGameRequest request) { + public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 1. 진행중인 게임 체크 if (gameSessionRepository.existsByUserIdAndSceneTypeNotDone(userId)) { @@ -218,8 +218,14 @@ public String startNewGame(Long userId, StartGameRequest request) { mysqlSession.setMongoId(savedMongo.getId()); gameSessionRepository.save(mysqlSession); + + StartGameResponse response = new StartGameResponse( + "게임이 생성되었습니다.", + mysqlSession.getMongoId() + ); + // 6. MongoDB PK 반환 - return savedMongo.getId(); + return response; } From c8d5bf077aab022d92998c56c777ee7f787fbc85 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 20:16:19 +0900 Subject: [PATCH 181/527] refactor: update method becuase type change --- .../java/com/scriptopia/demo/utils/GameBalanceUtil.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index fffef146..bbf5a375 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -2,7 +2,6 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; import java.util.List; @@ -18,7 +17,7 @@ public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse g List inventory = game.getInventory(); // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() + Map itemDefMap = itemDefs.stream() .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); // 착용 중인 무기 하나 찾기 @@ -72,7 +71,7 @@ public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse ga List inventory = game.getInventory(); // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() + Map itemDefMap = itemDefs.stream() .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); // 착용 중인 방어구 하나 찾기 @@ -114,7 +113,7 @@ public static void applyEquippedArtifactStats(ExternalGameResponse game) { List inventory = game.getInventory(); // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() + Map itemDefMap = itemDefs.stream() .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); // 착용 중인 아티팩트 하나 찾기 From 168625c9a58b4c4306503b98493db410b58f6f7f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 20:41:27 +0900 Subject: [PATCH 182/527] before test --- .../com/scriptopia/demo/repository/GameSessionRepository.java | 4 +++- .../java/com/scriptopia/demo/service/GameSessionService.java | 2 +- src/main/resources/application.yml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index af328016..c4de5b12 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -11,5 +11,7 @@ public interface GameSessionRepository extends JpaRepository List findAllByUser_Id(Long userId); - boolean existsByUserIdAndSceneTypeNotDone(Long userId); + boolean existsByUser_Id(Long userId); + + } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 65be536c..a0a06244 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -74,7 +74,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 1. 진행중인 게임 체크 - if (gameSessionRepository.existsByUserIdAndSceneTypeNotDone(userId)) { + if (gameSessionRepository.existsByUser_Id(userId)) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ddacbe7c..150b0a7a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,7 @@ spring: starttls: enable: true +reset-url: ${RESET_URL} auth: jwt: From 535015925a00e213be61041b790d96ec9d583001 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 21:24:27 +0900 Subject: [PATCH 183/527] change snake_case to carmelCase FASTAPI --- .../controller/GameSessionController.java | 7 +++ .../com/scriptopia/demo/domain/MainStat.java | 13 +++-- .../dto/gamesession/ExternalGameResponse.java | 35 +++++++------- .../demo/service/GameSessionService.java | 47 ++++++++++--------- .../demo/utils/GameBalanceUtil.java | 42 ++++++++--------- 5 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index ebc16e84..145078ca 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -4,8 +4,13 @@ import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.service.GameSessionService; +import io.jsonwebtoken.Jwt; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -46,7 +51,9 @@ public ResponseEntity startNewGame( @RequestBody StartGameRequest request, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + StartGameResponse response = gameSessionService.startNewGame(userId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/scriptopia/demo/domain/MainStat.java b/src/main/java/com/scriptopia/demo/domain/MainStat.java index 7776ca58..4a41eaaa 100644 --- a/src/main/java/com/scriptopia/demo/domain/MainStat.java +++ b/src/main/java/com/scriptopia/demo/domain/MainStat.java @@ -1,9 +1,14 @@ package com.scriptopia.demo.domain; +import com.fasterxml.jackson.annotation.JsonProperty; + public enum MainStat { + @JsonProperty("intelligence") + INTELLIGENCE, + @JsonProperty("strength") STRENGTH, + @JsonProperty("agility") AGILITY, - INTELLIGENCE, - LUCK, - NONE -} \ No newline at end of file + @JsonProperty("luck") + LUCK +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 1f432151..37b5ab15 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -13,12 +13,11 @@ @AllArgsConstructor @NoArgsConstructor public class ExternalGameResponse { - - private PlayerInfo player_info; + private PlayerInfo playerInfo; private List inventory; - private List item_def; - private String world_view; - private String background_story; + private List itemDef; + private String worldView; + private String backgroundStory; @Data @AllArgsConstructor @@ -27,9 +26,9 @@ public static class PlayerInfo { private String name; private int life; private int level; - private int experience_point; - private int combat_point; - private int health_point; + private int experiencePoint; + private int combatPoint; + private int healthPoint; private String trait; private int strength; private int agility; @@ -42,8 +41,8 @@ public static class PlayerInfo { @AllArgsConstructor @NoArgsConstructor public static class InventoryItem { - private int item_def_id; - private String acquired_at; + private int itemDefId; + private String acquiredAt; private boolean equipped; private String source; } @@ -52,18 +51,18 @@ public static class InventoryItem { @AllArgsConstructor @NoArgsConstructor public static class ItemDef { - private Long item_def_id; - private String item_pic_src; + private Long itemDefId; + private String itemPicSrc; private String name; private String description; private ItemType category; - private int base_stat; - private List item_effect; + private int baseStat; + private List itemEffect; private int strength; private int agility; private int intelligence; private int luck; - private MainStat main_stat; + private MainStat mainStat; private int weight; private Grade grade; private int price; @@ -72,10 +71,10 @@ public static class ItemDef { @AllArgsConstructor @NoArgsConstructor public static class ItemEffect { - private String item_effect_name; - private String item_effect_description; + private String itemEffectName; + private String itemEffectDescription; private Grade grade; - private int item_effect_weight; + private int itemEffectWeight; } } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index a0a06244..e6f7607e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -73,6 +73,8 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { @Transactional public StartGameResponse startNewGame(Long userId, StartGameRequest request) { + + // 1. 진행중인 게임 체크 if (gameSessionRepository.existsByUser_Id(userId)) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); @@ -102,11 +104,13 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } + + // 3. 밸런스 재세팅 - ExternalGameResponse.PlayerInfo player = externalGame.getPlayer_info(); + ExternalGameResponse.PlayerInfo player = externalGame.getPlayerInfo(); player.setLife(5); player.setLevel(1); - player.setExperience_point(0); + player.setExperiencePoint(0); // 4. 아이템 적용 및 전투력 계산 GameBalanceUtil.applyEquippedWeaponStatsAndCombatPoint(externalGame); @@ -127,8 +131,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 아이템 정보 List mongoInventory = externalGame.getInventory().stream() .map(inv -> new InventoryItemMongo( - inv.getItem_def_id(), - inv.getAcquired_at(), + inv.getItemDefId(), + inv.getAcquiredAt(), inv.isEquipped(), inv.getSource() )) @@ -136,27 +140,27 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setInventory(mongoInventory); // ItemDef - List mongoItemDefs = externalGame.getItem_def().stream() + List mongoItemDefs = externalGame.getItemDef().stream() .map(item -> new ItemDefMongo( - item.getItem_def_id(), - item.getItem_pic_src(), + item.getItemDefId(), + item.getItemPicSrc(), item.getName(), item.getDescription(), item.getCategory(), - item.getBase_stat(), - item.getItem_effect().stream() + item.getBaseStat(), + item.getItemEffect().stream() .map(e -> new ItemEffectMongo( - e.getItem_effect_name(), - e.getItem_effect_description(), + e.getItemEffectName(), + e.getItemEffectDescription(), e.getGrade(), - e.getItem_effect_weight() + e.getItemEffectWeight() )) .toList(), item.getStrength(), item.getAgility(), item.getIntelligence(), item.getLuck(), - item.getMain_stat(), + item.getMainStat(), item.getWeight(), item.getGrade(), item.getPrice() @@ -165,14 +169,14 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setItemDef(mongoItemDefs); // 플레이어 정보 - ExternalGameResponse.PlayerInfo p = externalGame.getPlayer_info(); + ExternalGameResponse.PlayerInfo p = externalGame.getPlayerInfo(); PlayerInfoMongo playerMongo = new PlayerInfoMongo( p.getName(), p.getLife(), p.getLevel(), - p.getExperience_point(), - p.getCombat_point(), - p.getHealth_point(), + p.getExperiencePoint(), + p.getCombatPoint(), + p.getHealthPoint(), p.getTrait(), p.getStrength(), p.getAgility(), @@ -184,8 +188,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 초기 히스토리 저장 HistoryInfoMongo history = new HistoryInfoMongo(); - history.setWorldView(externalGame.getWorld_view()); - history.setBackgroundStory(externalGame.getBackground_story()); + history.setWorldView(externalGame.getWorldView()); + history.setBackgroundStory(externalGame.getBackgroundStory()); mongoSession.setHistoryInfo(history); @@ -204,7 +208,6 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setShopInfo(new ShopInfoMongo()); mongoSession.setBattleInfo(new BattleInfoMongo()); mongoSession.setRewardInfo(new RewardInfoMongo()); - mongoSession.setHistoryInfo(new HistoryInfoMongo()); GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); @@ -219,13 +222,13 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { gameSessionRepository.save(mysqlSession); - StartGameResponse response = new StartGameResponse( + StartGameResponse startGameResponse = new StartGameResponse( "게임이 생성되었습니다.", mysqlSession.getMongoId() ); // 6. MongoDB PK 반환 - return response; + return startGameResponse; } diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index bbf5a375..e5528fbc 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -12,18 +12,18 @@ public class GameBalanceUtil { // 착용 무기 적용 후 캐릭터 스탯 및 combat_point 계산 public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); - List itemDefs = game.getItem_def(); + ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); + List itemDefs = game.getItemDef(); List inventory = game.getInventory(); // item_def_id 기준 Map 생성 Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); // 착용 중인 무기 하나 찾기 ExternalGameResponse.ItemDef equippedWeapon = inventory.stream() .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .map(inv -> itemDefMap.get(inv.getItemDefId())) .filter(item -> item != null && item.getCategory() == ItemType.WEAPON) .findFirst() .orElse(null); @@ -36,11 +36,11 @@ public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse g player.setLuck(player.getLuck() + equippedWeapon.getLuck()); // 2. combat_point 계산 - double effectMultiplier = equippedWeapon.getItem_effect().stream() + double effectMultiplier = equippedWeapon.getItemEffect().stream() .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) .sum(); - int mainStatValue = switch (equippedWeapon.getMain_stat()) { + int mainStatValue = switch (equippedWeapon.getMainStat()) { case STRENGTH -> player.getStrength(); case AGILITY -> player.getAgility(); case INTELLIGENCE -> player.getIntelligence(); @@ -50,9 +50,9 @@ public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse g double mainStatMultiplier = getMainStatMultiplier(mainStatValue); - int combatPoint = (int) (equippedWeapon.getBase_stat() * (1 + effectMultiplier) * mainStatMultiplier); + int combatPoint = (int) (equippedWeapon.getBaseStat() * (1 + effectMultiplier) * mainStatMultiplier); - player.setCombat_point(combatPoint); + player.setCombatPoint(combatPoint); } else { // 무기 미착용 시: 스탯 합 * 가장 높은 스탯 배율 int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); @@ -60,24 +60,24 @@ public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse g Math.max(player.getIntelligence(), player.getLuck())); double mainStatMultiplier = getMainStatMultiplier(maxStat); int combatPoint = (int) (totalStat * mainStatMultiplier); - player.setCombat_point(combatPoint); + player.setCombatPoint(combatPoint); } } // 착용 방어구 적용 후 캐릭터 스탯 및 health_point 계산 public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); - List itemDefs = game.getItem_def(); + ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); + List itemDefs = game.getItemDef(); List inventory = game.getInventory(); // item_def_id 기준 Map 생성 Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); // 착용 중인 방어구 하나 찾기 ExternalGameResponse.ItemDef equippedArmor = inventory.stream() .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .map(inv -> itemDefMap.get(inv.getItemDefId())) .filter(item -> item != null && item.getCategory() == ItemType.ARMOR) .findFirst() .orElse(null); @@ -90,36 +90,36 @@ public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse ga player.setLuck(player.getLuck() + equippedArmor.getLuck()); // 2. health_point 계산 - double effectMultiplier = equippedArmor.getItem_effect().stream() + double effectMultiplier = equippedArmor.getItemEffect().stream() .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) .sum(); - int baseHealth = equippedArmor.getBase_stat(); + int baseHealth = equippedArmor.getBaseStat(); int healthPoint = (int) (baseHealth * (1 + effectMultiplier)); - player.setHealth_point(healthPoint); + player.setHealthPoint(healthPoint); } else { // 방어구 미착용 시: 전체 스탯 합 * 3 int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); int healthPoint = totalStat * 3; - player.setHealth_point(healthPoint); + player.setHealthPoint(healthPoint); } } // 착용 아티팩트 적용 후 캐릭터 스탯 계산 (전투력/체력 보정은 추후) public static void applyEquippedArtifactStats(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayer_info(); - List itemDefs = game.getItem_def(); + ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); + List itemDefs = game.getItemDef(); List inventory = game.getInventory(); // item_def_id 기준 Map 생성 Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItem_def_id, item -> item)); + .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); // 착용 중인 아티팩트 하나 찾기 ExternalGameResponse.ItemDef equippedArtifact = inventory.stream() .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItem_def_id())) + .map(inv -> itemDefMap.get(inv.getItemDefId())) .filter(item -> item != null && item.getCategory() == ItemType.ARTIFACT) .findFirst() .orElse(null); From 611773a7d72d7db302c2b1a4cec4bf1b57af611d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 31 Aug 2025 21:40:55 +0900 Subject: [PATCH 184/527] refactor/ sharedGameService CustomException handler add --- .../java/com/scriptopia/demo/service/SharedGameService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 5ad5a87b..dbdea22c 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -27,13 +27,13 @@ public class SharedGameService { @Transactional public ResponseEntity saveSharedGame(Long Id, Long historyId) { User user = userRepository.findById(Id) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); History history = historyRepository.findById(historyId) - .orElseThrow(() -> new RuntimeException("History not found")); + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); if(!history.getUser().getId().equals(Id)) { - return ResponseEntity.status(403).body("not your history"); + throw new CustomException(ErrorCode.E_401_NOT_EQUAL_SHARED_GAME); } SharedGame sharedGame = SharedGame.from(user, history); From 6e4b668b629c20bde9ae36a4798d23bd394c91e6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 21:47:37 +0900 Subject: [PATCH 185/527] before feat: createGameSession into my Item --- .../demo/controller/GameSessionController.java | 7 ------- .../demo/repository/UserItemRepository.java | 3 +++ .../demo/service/GameSessionService.java | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 145078ca..608cd1a2 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -1,16 +1,9 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.gamesession.GameSessionRequest; -import com.scriptopia.demo.dto.gamesession.GameSessionResponse; import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; -import com.scriptopia.demo.exception.CustomException; -import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.service.GameSessionService; -import io.jsonwebtoken.Jwt; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java index 3b8752ca..4c03b129 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java @@ -4,10 +4,13 @@ import com.scriptopia.demo.domain.TradeStatus; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserItem; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserItemRepository extends JpaRepository { Optional findByItemDefAndTradeStatus(ItemDef itemDef, TradeStatus tradeStatus); + + Optional findByUserIdAndItemId(Long userId, Long itemId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index e6f7607e..5904b458 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.GameSessionMongoRepository; import com.scriptopia.demo.repository.GameSessionRepository; +import com.scriptopia.demo.repository.UserItemRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.GameBalanceUtil; import lombok.RequiredArgsConstructor; @@ -27,9 +28,9 @@ public class GameSessionService { private final GameSessionRepository gameSessionRepository; private final UserRepository userRepository; - private final RestTemplateBuilder restTemplateBuilder; private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; + private final UserItemRepository userItemRepository; public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) @@ -74,13 +75,22 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { public StartGameResponse startNewGame(Long userId, StartGameRequest request) { - // 1. 진행중인 게임 체크 if (gameSessionRepository.existsByUser_Id(userId)) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); } + UserItem userItem = null; + + // 물건을 가져왔다면 그 물건이 해당 플레이어의 것인지, 존재하는 것인지 확인 + if (request.getItemId() != null){ + Long itemId = Long.parseLong(request.getItemId()); + userItem = userItemRepository.findByUserIdAndItemId(userId, itemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED)); + } + + CreateGameRequest createGameRequest = new CreateGameRequest( request.getBackground(), request.getCharacterName(), From 43aaa159f7d69342a06a98a3905f565abd3e1b82 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 22:21:46 +0900 Subject: [PATCH 186/527] complte test but change itemDef next number --- .../scriptopia/demo/domain/ItemEffect.java | 2 +- .../demo/domain/mongo/InventoryItemMongo.java | 3 - .../demo/domain/mongo/ItemDefMongo.java | 2 +- .../dto/gamesession/ExternalGameResponse.java | 7 +- .../demo/repository/UserItemRepository.java | 3 +- .../demo/service/AuctionService.java | 2 +- .../demo/service/GameSessionService.java | 128 ++++++++++++++---- .../demo/service/ItemDefService.java | 4 +- 8 files changed, 109 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java index 1244d508..8c39f417 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemEffect.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemEffect.java @@ -25,5 +25,5 @@ public class ItemEffect { private String effectName; // 아이템 효과 설명 - private String effect_description; + private String effectDescription; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java index e4fc2d56..74180600 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java @@ -13,7 +13,4 @@ public class InventoryItemMongo { private LocalDateTime acquiredAt; private Boolean equipped; private String source; - - public InventoryItemMongo(int itemDefId, String acquiredAt, boolean equipped, String source) { - } } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index a8363af0..11a6a417 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -30,5 +30,5 @@ public class ItemDefMongo { private MainStat mainStat; // strength, agility, intelligence, luck private Integer weight; private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY - private Integer price; + private Long price; } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 37b5ab15..d97414d1 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; import java.util.List; @Data @@ -41,8 +42,8 @@ public static class PlayerInfo { @AllArgsConstructor @NoArgsConstructor public static class InventoryItem { - private int itemDefId; - private String acquiredAt; + private Long itemDefId; + private LocalDateTime acquiredAt; private boolean equipped; private String source; } @@ -65,7 +66,7 @@ public static class ItemDef { private MainStat mainStat; private int weight; private Grade grade; - private int price; + private Long price; @Data @AllArgsConstructor diff --git a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java index 4c03b129..ac448e67 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java @@ -12,5 +12,6 @@ public interface UserItemRepository extends JpaRepository { Optional findByItemDefAndTradeStatus(ItemDef itemDef, TradeStatus tradeStatus); - Optional findByUserIdAndItemId(Long userId, Long itemId); + Optional findByUserIdAndItemDefId(Long userId, Long itemDefId); + } diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 08c53951..c9d07aab 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -137,7 +137,7 @@ public TradeResponse getTrades(TradeFilterRequest request) { .map(e -> { AuctionItemResponse.ItemEffectDto effDto = new AuctionItemResponse.ItemEffectDto(); effDto.setEffectName(e.getEffectName()); - effDto.setEffectDescription(e.getEffect_description()); + effDto.setEffectDescription(e.getEffectDescription()); effDto.setGrade(e.getEffectGradeDef().getGrade().name()); return effDto; }) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 5904b458..72a76e54 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -5,14 +5,9 @@ import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; -import com.scriptopia.demo.repository.GameSessionMongoRepository; -import com.scriptopia.demo.repository.GameSessionRepository; -import com.scriptopia.demo.repository.UserItemRepository; -import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.repository.*; import com.scriptopia.demo.utils.GameBalanceUtil; import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +15,10 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Random; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -31,6 +28,7 @@ public class GameSessionService { private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; + private final ItemDefRepository itemDefRepository; public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) @@ -86,7 +84,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 물건을 가져왔다면 그 물건이 해당 플레이어의 것인지, 존재하는 것인지 확인 if (request.getItemId() != null){ Long itemId = Long.parseLong(request.getItemId()); - userItem = userItemRepository.findByUserIdAndItemId(userId, itemId) + userItem = userItemRepository.findByUserIdAndItemDefId(userId, itemId) .orElseThrow(() -> new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED)); } @@ -139,33 +137,31 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 아이템 정보 - List mongoInventory = externalGame.getInventory().stream() - .map(inv -> new InventoryItemMongo( - inv.getItemDefId(), - inv.getAcquiredAt(), - inv.isEquipped(), - inv.getSource() - )) - .toList(); - mongoSession.setInventory(mongoInventory); - - // ItemDef - List mongoItemDefs = externalGame.getItemDef().stream() - .map(item -> new ItemDefMongo( + List mongoInventory = new ArrayList<>(); + List mongoItemDefs = new ArrayList<>(); + + // FAST API 아이템 변환 + if (externalGame.getItemDef() != null) { + for (var item : externalGame.getItemDef()) { + var effects = item.getItemEffect() != null + ? item.getItemEffect().stream() + .map(e -> new ItemEffectMongo( + e.getItemEffectName(), + e.getItemEffectDescription(), + e.getGrade(), + e.getItemEffectWeight() + )) + .toList() + : Collections.emptyList(); + + ItemDefMongo itemDefMongo = new ItemDefMongo( item.getItemDefId(), item.getItemPicSrc(), item.getName(), item.getDescription(), item.getCategory(), item.getBaseStat(), - item.getItemEffect().stream() - .map(e -> new ItemEffectMongo( - e.getItemEffectName(), - e.getItemEffectDescription(), - e.getGrade(), - e.getItemEffectWeight() - )) - .toList(), + (List) effects, item.getStrength(), item.getAgility(), item.getIntelligence(), @@ -174,10 +170,82 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { item.getWeight(), item.getGrade(), item.getPrice() - )) - .toList(); + ); + + mongoItemDefs.add(itemDefMongo); + } + } + + if (externalGame.getInventory() != null) { + for (var inv : externalGame.getInventory()) { + mongoInventory.add(new InventoryItemMongo( + inv.getItemDefId(), + inv.getAcquiredAt(), + inv.isEquipped(), + inv.getSource() + )); + } + } + + // 2. 사용자 아이템 추가 (두 번째 위치) + if (userItem != null) { + ItemDef userItemDef = userItem.getItemDef(); + + ItemDefMongo userItemDefMongo = new ItemDefMongo( + userItemDef.getId(), + userItemDef.getPicSrc(), + userItemDef.getName(), + userItemDef.getDescription(), + userItemDef.getItemType(), + userItemDef.getBaseStat(), + userItemDef.getItemEffects() != null + ? userItemDef.getItemEffects().stream() + .map(e -> new ItemEffectMongo( + e.getEffectName(), + e.getEffectDescription(), + e.getEffectGradeDef().getGrade(), + 1 + )) + .toList() + : Collections.emptyList(), + userItemDef.getStrength(), + userItemDef.getAgility(), + userItemDef.getIntelligence(), + userItemDef.getLuck(), + userItemDef.getMainStat(), + 1, + userItemDef.getItemGradeDef().getGrade(), + userItemDef.getPrice() + ); + + // 아이템 정의 리스트 두 번째 위치에 삽입 + if (mongoItemDefs.size() >= 1) { + mongoItemDefs.add(1, userItemDefMongo); + } else { + mongoItemDefs.add(userItemDefMongo); + } + + // Inventory 리스트에도 두 번째 위치에 삽입 + InventoryItemMongo userInventoryMongo = new InventoryItemMongo( + userItemDefMongo.getItemDefId(), + LocalDateTime.now(), + false, + "USER_ITEM" + ); + + if (mongoInventory.size() >= 1) { + mongoInventory.add(1, userInventoryMongo); + } else { + mongoInventory.add(userInventoryMongo); + } + } + +// 3. MongoSession 저장 + mongoSession.setInventory(mongoInventory); mongoSession.setItemDef(mongoItemDefs); + + // 플레이어 정보 ExternalGameResponse.PlayerInfo p = externalGame.getPlayerInfo(); PlayerInfoMongo playerMongo = new PlayerInfoMongo( diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 2d744102..5dbb0867 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -59,7 +59,7 @@ public ItemDefResponse createItem(ItemDefRequest dto) { effect.setItemDef(itemDef); effect.setEffectGradeDef(effectGradeDef); effect.setEffectName(effectDto.getEffectName()); - effect.setEffect_description(effectDto.getEffectDescription()); + effect.setEffectDescription(effectDto.getEffectDescription()); itemDef.getItemEffects().add(effect); } @@ -93,7 +93,7 @@ private ItemDefResponse toResponse(ItemDef itemDef) { .map(effect -> { ItemEffectResponse eResp = new ItemEffectResponse(); eResp.setEffectName(effect.getEffectName()); - eResp.setEffectDescription(effect.getEffect_description()); + eResp.setEffectDescription(effect.getEffectDescription()); eResp.setGrade(effect.getEffectGradeDef().getGrade().name()); return eResp; }) From e6260810f3ef40007eeb2240f63f76f38d31ece4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 31 Aug 2025 22:25:17 +0900 Subject: [PATCH 187/527] complete api test --- .../com/scriptopia/demo/service/GameSessionService.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 72a76e54..ac14a1e9 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -187,12 +187,19 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { } } + long maxItemDefId = mongoItemDefs.stream() + .mapToLong(ItemDefMongo::getItemDefId) + .max() + .orElse(0L); + // 2. 사용자 아이템 추가 (두 번째 위치) if (userItem != null) { ItemDef userItemDef = userItem.getItemDef(); + long newItemDefId = maxItemDefId + 1; + ItemDefMongo userItemDefMongo = new ItemDefMongo( - userItemDef.getId(), + newItemDefId, userItemDef.getPicSrc(), userItemDef.getName(), userItemDef.getDescription(), From 0c96650f75c99a936e983c041f377da98f7ed32d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 23:57:28 +0900 Subject: [PATCH 188/527] feat: implement GoogleClient util class for get user info --- .../scriptopia/demo/utils/GoogleClient.java | 71 +++++++++++++++++++ src/main/resources/application.yml | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/utils/GoogleClient.java diff --git a/src/main/java/com/scriptopia/demo/utils/GoogleClient.java b/src/main/java/com/scriptopia/demo/utils/GoogleClient.java new file mode 100644 index 00000000..5a1c8b4b --- /dev/null +++ b/src/main/java/com/scriptopia/demo/utils/GoogleClient.java @@ -0,0 +1,71 @@ +package com.scriptopia.demo.utils; + +import com.scriptopia.demo.dto.oauth.OAuthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class GoogleClient { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + @Value("${oauth.google.redirect-uri}") + private String redirectUri; + + private final RestTemplate restTemplate = new RestTemplate(); + + public OAuthUserInfo getUserInfo(String code) { + // 1. Authorization Code -> Access Token 교환 + String tokenUrl = "https://oauth2.googleapis.com/token"; + + Map params = new HashMap<>(); + params.put("code", code); + params.put("client_id", clientId); + params.put("client_secret", clientSecret); + params.put("redirect_uri", redirectUri); + params.put("grant_type", "authorization_code"); + + ResponseEntity tokenResponse = + restTemplate.postForEntity(tokenUrl, params, Map.class); + + String accessToken = (String) tokenResponse.getBody().get("access_token"); + + // 2. Access Token으로 사용자 정보 조회 + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity userInfoResponse = + restTemplate.exchange( + "https://www.googleapis.com/oauth2/v2/userinfo", + HttpMethod.GET, + entity, + Map.class + ); + + Map userInfo = userInfoResponse.getBody(); + + // 3. DTO 매핑 + return OAuthUserInfo.builder() + .id((String) userInfo.get("id")) + .email((String) userInfo.get("email")) + .name((String) userInfo.get("name")) + .profileImage((String) userInfo.get("picture")) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6c28214..5c9725ae 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true From 7be71b354f94c5093fed06182fbee6e766293d34 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 31 Aug 2025 23:58:49 +0900 Subject: [PATCH 189/527] feat: add method in social account repository --- src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java | 1 + .../com/scriptopia/demo/repository/SocialAccountRepository.java | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java index 4c62695d..271d031f 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java @@ -4,6 +4,7 @@ import lombok.*; @Data +@Builder @AllArgsConstructor @NoArgsConstructor public class OAuthUserInfo { diff --git a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java index 1ba10390..0eee69a6 100644 --- a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java @@ -9,7 +9,5 @@ public interface SocialAccountRepository extends JpaRepository { - Optional findByEmail(String email); - Optional findBySocialIdAndProvider(String id, Provider provider); } From c2e0cb7bb09c066b348e9b7d305e7e28abdd8c59 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:03:15 +0900 Subject: [PATCH 190/527] feat: update entities socialAccount -> email is unique and not nullable attribute user -> nickname is unique attribute --- src/main/java/com/scriptopia/demo/domain/SocialAccount.java | 1 + src/main/java/com/scriptopia/demo/domain/User.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java index 2b7b27b6..9235fab6 100644 --- a/src/main/java/com/scriptopia/demo/domain/SocialAccount.java +++ b/src/main/java/com/scriptopia/demo/domain/SocialAccount.java @@ -19,6 +19,7 @@ public class SocialAccount{ private String socialId; + @Column(unique = true, nullable = false) private String email; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/scriptopia/demo/domain/User.java b/src/main/java/com/scriptopia/demo/domain/User.java index db76c4e1..a2c4486f 100644 --- a/src/main/java/com/scriptopia/demo/domain/User.java +++ b/src/main/java/com/scriptopia/demo/domain/User.java @@ -16,7 +16,7 @@ public class User { @Id @GeneratedValue private Long id; - + @Column(nullable = false, unique = true) private String nickname; private Long pia; From dbc51216b38faf19c4e90a92184133405de80bdf Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:11:25 +0900 Subject: [PATCH 191/527] feat: add error codes for social login --- .../com/scriptopia/demo/exception/ErrorCode.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index d0ae12c3..42d3ae05 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -32,6 +32,9 @@ public enum ErrorCode { E_400_PIA_ITEM_DUPLICATE("E400019", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_REQUEST("E400020", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_GAME_ALREADY_IN_PROGRESS("E400021", "진행 중인 게임이 이미 존재합니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_SOCIAL_LOGIN_CODE("E400022", "유효하지 않거나 만료된 인증 코드입니다.", HttpStatus.BAD_REQUEST), + E_400_NO_EMAIL("E400023", "소셜 계정에서 이메일 정보를 제공하지 않았습니다.", HttpStatus.BAD_REQUEST), + E_400_UNSUPPORTED_PROVIDER("E400024", "지원하지 않는 소셜 로그인 공급자입니다.", HttpStatus.BAD_REQUEST), @@ -76,7 +79,14 @@ public enum ErrorCode { //500 Internal Server Error E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), E_500_TOKEN_HASHING_FAILED("E_500001","리프레쉬 토큰 해싱에 실패했습니다.",HttpStatus.INTERNAL_SERVER_ERROR), - E_500_EXTERNAL_API_ERROR("E500001", "외부 게임 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + E_500_EXTERNAL_API_ERROR("E500002", "외부 게임 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_DATABASE_ERROR("E500003", "데이터베이스 처리 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_TOKEN_CREATION_FAILED("E500004", "인증 토큰 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_TOKEN_STORAGE_FAILED("E500005", "리프레시 토큰 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + //502 BAD_GATEWAY + E_502_OAUTH_SERVER_ERROR("E502001", "소셜 로그인 서버와의 통신에 실패했습니다.", HttpStatus.BAD_GATEWAY); + private final String code; From 1de8a92e9cf41c0c495206c755cb6b678b8b8c98 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:35:27 +0900 Subject: [PATCH 192/527] feat: add attribute provider in OAuthUserInfo dto --- src/main/java/com/scriptopia/demo/utils/GoogleClient.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/GoogleClient.java b/src/main/java/com/scriptopia/demo/utils/GoogleClient.java index 5a1c8b4b..efb21975 100644 --- a/src/main/java/com/scriptopia/demo/utils/GoogleClient.java +++ b/src/main/java/com/scriptopia/demo/utils/GoogleClient.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.utils; +import com.scriptopia.demo.domain.Provider; import com.scriptopia.demo.dto.oauth.OAuthUserInfo; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -29,7 +30,7 @@ public class GoogleClient { private final RestTemplate restTemplate = new RestTemplate(); public OAuthUserInfo getUserInfo(String code) { - // 1. Authorization Code -> Access Token 교환 + String tokenUrl = "https://oauth2.googleapis.com/token"; Map params = new HashMap<>(); @@ -44,7 +45,7 @@ public OAuthUserInfo getUserInfo(String code) { String accessToken = (String) tokenResponse.getBody().get("access_token"); - // 2. Access Token으로 사용자 정보 조회 + HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); @@ -60,12 +61,13 @@ public OAuthUserInfo getUserInfo(String code) { Map userInfo = userInfoResponse.getBody(); - // 3. DTO 매핑 + return OAuthUserInfo.builder() .id((String) userInfo.get("id")) .email((String) userInfo.get("email")) .name((String) userInfo.get("name")) .profileImage((String) userInfo.get("picture")) + .provider(Provider.GOOGLE.toString()) .build(); } } From cc9282afa9e947c01b4a7a534ce60df20e26e15e Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:36:44 +0900 Subject: [PATCH 193/527] feat: update dto and repository --- .../scriptopia/demo/dto/oauth/OAuthLoginResponse.java | 9 ++------- .../com/scriptopia/demo/dto/oauth/OAuthUserInfo.java | 2 ++ .../scriptopia/demo/dto/oauth/SocialSignupRequest.java | 4 +--- .../demo/repository/SocialAccountRepository.java | 2 ++ 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java index ac882dea..14c94d1b 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthLoginResponse.java @@ -10,13 +10,8 @@ @AllArgsConstructor @NoArgsConstructor public class OAuthLoginResponse { - private LoginStatus status; // LOGIN_SUCCESS or SIGNUP_REQUIRED + private LoginStatus status; private String accessToken; - private String refreshToken; - - // 회원가입 필요할 때만 내려줌 - private String socialId; - private String email; - private String provider; + private String signupToken; } diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java index 271d031f..fd5b839a 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java @@ -12,4 +12,6 @@ public class OAuthUserInfo { private String email; private String name; private String profileImage; + private String provider; + } diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java index 7998f15d..49158591 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java @@ -10,9 +10,7 @@ @AllArgsConstructor @NoArgsConstructor public class SocialSignupRequest { - private String provider; - private String socialId; - private String email; private String nickname; private String deviceId; + private String signupToken; } diff --git a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java index 0eee69a6..d0a30c98 100644 --- a/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SocialAccountRepository.java @@ -10,4 +10,6 @@ public interface SocialAccountRepository extends JpaRepository { Optional findBySocialIdAndProvider(String id, Provider provider); + + boolean existsBySocialIdAndProvider(String socialId, Provider provider); } From 39d97f42a3658af3d364c9abcb3217d88a0ecd16 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:37:30 +0900 Subject: [PATCH 194/527] feat: implement social account service implement login method implement signup method --- .../scriptopia/demo/service/OAuthService.java | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java index cc4b5713..8c8ab0d9 100644 --- a/src/main/java/com/scriptopia/demo/service/OAuthService.java +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -1,11 +1,189 @@ package com.scriptopia.demo.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.dto.localaccount.LoginResponse; +import com.scriptopia.demo.dto.oauth.LoginStatus; +import com.scriptopia.demo.dto.oauth.OAuthLoginResponse; +import com.scriptopia.demo.dto.oauth.OAuthUserInfo; +import com.scriptopia.demo.dto.oauth.SocialSignupRequest; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.SocialAccountRepository; +import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.utils.GoogleClient; +import com.scriptopia.demo.utils.JwtProvider; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpClientErrorException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor public class OAuthService { + private static final String RT_COOKIE = "RT"; + private static final boolean COOKIE_SECURE = true; + private static final String COOKIE_SAMESITE = "None"; + + private final UserRepository userRepository; + private final SocialAccountRepository socialAccountRepository; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; // RefreshToken 관리 서비스 + private final GoogleClient googleClient; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Transactional + public OAuthLoginResponse login(String provider, String code, + HttpServletRequest request, HttpServletResponse response) throws JsonProcessingException { + + Provider providerEnum = Provider.valueOf(provider.toUpperCase()); + + OAuthUserInfo userInfo = fetchUserInfoFromProvider(provider, code); + + Optional accountOpt = + socialAccountRepository.findBySocialIdAndProvider(userInfo.getId(), providerEnum); + + String json = objectMapper.writeValueAsString(userInfo); + if (accountOpt.isEmpty()) { + String signupToken = UUID.randomUUID().toString(); + // Redis TTL = 5분 + redisTemplate.opsForValue().set("signup:" + signupToken, json, 5, TimeUnit.MINUTES); + + return OAuthLoginResponse.builder() + .status(LoginStatus.SIGNUP_REQUIRED) + .signupToken(signupToken) + .build(); + } + + User user = accountOpt.get().getUser(); + user.setLastLoginAt(LocalDateTime.now()); + + try { + List roles = List.of(user.getRole().toString()); + String access = jwtProvider.createAccessToken(user.getId(), roles); + String refresh = jwtProvider.createRefreshToken(user.getId(), "SOCIAL-" + provider); + + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + refreshTokenService.saveLoginRefresh(user.getId(), refresh, "SOCIAL-" + provider, ip, ua); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); + + return OAuthLoginResponse.builder() + .status(LoginStatus.LOGIN_SUCCESS) + .accessToken(access) + .build(); + + } catch (JwtException e) { + throw new CustomException(ErrorCode.E_500_TOKEN_CREATION_FAILED); + } catch (Exception e) { + throw new CustomException(ErrorCode.E_500_TOKEN_STORAGE_FAILED); + } + } + + @Transactional + public OAuthLoginResponse signup(SocialSignupRequest req, + HttpServletRequest request, HttpServletResponse response) { + String key = "signup:" + req.getSignupToken(); + + try { + + String json = (String) redisTemplate.opsForValue().get(key); + if (json == null) { + throw new CustomException(ErrorCode.E_400_INVALID_SOCIAL_LOGIN_CODE); + } + + OAuthUserInfo userInfo = objectMapper.readValue(json, OAuthUserInfo.class); + + Provider provider = Provider.valueOf(userInfo.getProvider().toUpperCase()); + + if (socialAccountRepository.existsBySocialIdAndProvider(userInfo.getId(), provider)) { + throw new CustomException(ErrorCode.E_409_EMAIL_TAKEN); + } + + if (userRepository.existsByNickname(req.getNickname())) { + throw new CustomException(ErrorCode.E_409_NICKNAME_TAKEN); + } + + + User user = new User(); + user.setNickname(req.getNickname()); + user.setPia(0L); + user.setCreatedAt(LocalDateTime.now()); + user.setLastLoginAt(LocalDateTime.now()); + user.setProfileImgUrl(null); + user.setRole(Role.USER); + user.setLoginType(LoginType.SOCIAL); + userRepository.save(user); + + SocialAccount account = new SocialAccount(); + account.setUser(user); + account.setSocialId(userInfo.getId()); + account.setEmail(userInfo.getEmail()); + account.setProvider(provider); + socialAccountRepository.save(account); + + List roles = List.of(user.getRole().toString()); + String deviceId = "SOCIAL-" + provider.name(); + String access = jwtProvider.createAccessToken(user.getId(), roles); + String refresh = jwtProvider.createRefreshToken(user.getId(), deviceId); + + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + refreshTokenService.saveLoginRefresh(user.getId(), refresh, deviceId, ip, ua); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); + + redisTemplate.delete(key); + + return OAuthLoginResponse.builder() + .status(LoginStatus.LOGIN_SUCCESS) + .accessToken(access) + .build(); + + } catch (JsonProcessingException e) { + throw new CustomException(ErrorCode.E_500_DATABASE_ERROR); + } + } + + public ResponseCookie refreshCookie(String value) { + return ResponseCookie.from(RT_COOKIE, value) + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); + } + + private OAuthUserInfo fetchUserInfoFromProvider(String provider, String code) { + switch (provider.toLowerCase()) { + case "google": + return googleClient.getUserInfo(code); + case "naver": + // return naverClient.getUserInfo(code); + case "kakao": + // return kakaoClient.getUserInfo(code); + default: + throw new IllegalArgumentException("지원하지 않는 provider: " + provider); + } + } } From 204fc96e374cf028cbab0ce958698ec4a7cd90f5 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 00:38:13 +0900 Subject: [PATCH 195/527] feat: create api for google social login and register --- .../demo/controller/OAuthController.java | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index 63eae722..ba018aa8 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -1,12 +1,41 @@ package com.scriptopia.demo.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.scriptopia.demo.dto.localaccount.LoginResponse; +import com.scriptopia.demo.dto.oauth.OAuthLoginResponse; +import com.scriptopia.demo.dto.oauth.SocialSignupRequest; +import com.scriptopia.demo.service.OAuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/public/oauth") @RequiredArgsConstructor public class OAuthController { + private final OAuthService oAuthService; + + @GetMapping("/{provider}") + public ResponseEntity login( + @PathVariable("provider") String provider, + @RequestParam("code") String code, + HttpServletRequest request, + HttpServletResponse response + ) throws JsonProcessingException { + OAuthLoginResponse result = oAuthService.login(provider, code, request, response); + return ResponseEntity.ok(result); + } + + @PostMapping("/register") + public ResponseEntity signup( + @RequestBody SocialSignupRequest req, + HttpServletRequest request, + HttpServletResponse response + ) { + OAuthLoginResponse result = oAuthService.signup(req, request, response); + return ResponseEntity.ok(result); + } } From 69f9b2f14584003164c00cb4863583a98dc38a5c Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 01:14:14 +0900 Subject: [PATCH 196/527] feat: add scope data in oauth.google --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6c28214..e8fa4d92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,7 +41,7 @@ oauth: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_SECRET_KEY} redirect-uri: ${GOOGLE_REDIRECT_URI} - + scope: email profile auth: From 4b31c668975b560f744bc4d26bee9ed44d93829d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 01:15:24 +0900 Subject: [PATCH 197/527] feat: create OAuthProperties for provide oauth login url --- .../demo/config/OAuthProperties.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/OAuthProperties.java diff --git a/src/main/java/com/scriptopia/demo/config/OAuthProperties.java b/src/main/java/com/scriptopia/demo/config/OAuthProperties.java new file mode 100644 index 00000000..52d5c6d3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/OAuthProperties.java @@ -0,0 +1,26 @@ +package com.scriptopia.demo.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "oauth") +public class OAuthProperties { + + private ProviderProperties google; + private ProviderProperties kakao; + private ProviderProperties naver; + + @Getter + @Setter + public static class ProviderProperties { + private String clientId; + private String clientSecret; + private String redirectUri; + private String scope; + } +} From 00a43ea929f1c3734cf2e93d374423eb6df63bcb Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 01:16:11 +0900 Subject: [PATCH 198/527] feat: implement provide Oauth login url to client --- .../scriptopia/demo/service/OAuthService.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java index 8c8ab0d9..a4faef3c 100644 --- a/src/main/java/com/scriptopia/demo/service/OAuthService.java +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.config.OAuthProperties; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.localaccount.LoginResponse; import com.scriptopia.demo.dto.oauth.LoginStatus; @@ -49,6 +50,7 @@ public class OAuthService { private final GoogleClient googleClient; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final OAuthProperties props; @Transactional public OAuthLoginResponse login(String provider, String code, @@ -174,6 +176,31 @@ public ResponseCookie refreshCookie(String value) { .build(); } + public String buildAuthorizationUrl(String provider) { + switch (provider.toUpperCase()) { + case "GOOGLE": + return "https://accounts.google.com/o/oauth2/v2/auth" + + "?client_id=" + props.getGoogle().getClientId() + + "&redirect_uri=" + props.getGoogle().getRedirectUri() + + "&response_type=code" + + "&scope=" + props.getGoogle().getScope(); + case "KAKAO": + return "https://kauth.kakao.com/oauth/authorize" + + "?client_id=" + props.getKakao().getClientId() + + "&redirect_uri=" + props.getKakao().getRedirectUri() + + "&response_type=code"; + case "NAVER": + return "https://nid.naver.com/oauth2.0/authorize" + + "?client_id=" + props.getNaver().getClientId() + + "&redirect_uri=" + props.getNaver().getRedirectUri() + + "&response_type=code" + + "&state=" + UUID.randomUUID(); + default: + throw new CustomException(ErrorCode.E_400_UNSUPPORTED_PROVIDER); + } + } + + private OAuthUserInfo fetchUserInfoFromProvider(String provider, String code) { switch (provider.toLowerCase()) { case "google": From b812d3df49deb161696dd1d8f58fbff19926c71f Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 1 Sep 2025 01:16:37 +0900 Subject: [PATCH 199/527] feat: create api /public/oauth/authorize provide Oauth login url --- .../java/com/scriptopia/demo/controller/OAuthController.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index ba018aa8..4d8720de 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -38,4 +38,9 @@ public ResponseEntity signup( OAuthLoginResponse result = oAuthService.signup(req, request, response); return ResponseEntity.ok(result); } + + @GetMapping("/authorize") + public ResponseEntity getAuthorizationUrl(@RequestParam("provider") String provider) { + return ResponseEntity.ok(oAuthService.buildAuthorizationUrl(provider)); + } } From 71e1b4d199f180c215d11e8976f5bcab5f74da90 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Mon, 1 Sep 2025 13:43:30 +0900 Subject: [PATCH 200/527] feat: update application.yml add kakao oauth info add naver oauth info --- src/main/resources/application.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e8fa4d92..0264f1b7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true @@ -42,6 +42,17 @@ oauth: client-secret: ${GOOGLE_SECRET_KEY} redirect-uri: ${GOOGLE_REDIRECT_URI} scope: email profile + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + scope: account_email profile_nickname profile_image + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_SECRET} + redirect-uri: ${NAVER_REDIRECT_URI} + scope: name email profile_image + auth: From 7dba5e2ebac99f5bcde55e5a19536256f07cd338 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Mon, 1 Sep 2025 13:46:08 +0900 Subject: [PATCH 201/527] feat: create NAverClient for get user info --- .../demo/service/LocalAccountService.java | 3 +- .../scriptopia/demo/service/OAuthService.java | 6 +- .../scriptopia/demo/utils/NaverClient.java | 69 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/utils/NaverClient.java diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 02b77777..dd81bace 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -40,11 +40,12 @@ public class LocalAccountService { private final JwtProvider jwt; private final RefreshTokenService refreshService; private final JwtProperties prop; + private final MailService mailService; private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; - private final MailService mailService; + private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java index a4faef3c..3970e1ce 100644 --- a/src/main/java/com/scriptopia/demo/service/OAuthService.java +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -15,6 +15,7 @@ import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.GoogleClient; import com.scriptopia.demo.utils.JwtProvider; +import com.scriptopia.demo.utils.NaverClient; import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -48,6 +49,7 @@ public class OAuthService { private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; // RefreshToken 관리 서비스 private final GoogleClient googleClient; + private final NaverClient naverClient; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final OAuthProperties props; @@ -206,11 +208,11 @@ private OAuthUserInfo fetchUserInfoFromProvider(String provider, String code) { case "google": return googleClient.getUserInfo(code); case "naver": - // return naverClient.getUserInfo(code); + return naverClient.getUserInfo(code); case "kakao": // return kakaoClient.getUserInfo(code); default: - throw new IllegalArgumentException("지원하지 않는 provider: " + provider); + throw new CustomException(ErrorCode.E_400_UNSUPPORTED_PROVIDER); } } } diff --git a/src/main/java/com/scriptopia/demo/utils/NaverClient.java b/src/main/java/com/scriptopia/demo/utils/NaverClient.java new file mode 100644 index 00000000..80429d10 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/utils/NaverClient.java @@ -0,0 +1,69 @@ +package com.scriptopia.demo.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.config.OAuthProperties; +import com.scriptopia.demo.dto.oauth.OAuthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NaverClient { + + private final RestTemplate restTemplate; + private final OAuthProperties props; + private final ObjectMapper objectMapper; + + public OAuthUserInfo getUserInfo(String code) { + // 액세스 토큰 발급 + String tokenUrl = "https://nid.naver.com/oauth2.0/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", props.getNaver().getClientId()); + params.add("client_secret", props.getNaver().getClientSecret()); + params.add("redirect_uri", props.getNaver().getRedirectUri()); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + ResponseEntity tokenRes = restTemplate.postForEntity(tokenUrl, request, Map.class); + + String accessToken = (String) tokenRes.getBody().get("access_token"); + + // 사용자 정보 조회 + HttpHeaders userHeaders = new HttpHeaders(); + userHeaders.setBearerAuth(accessToken); + HttpEntity userReq = new HttpEntity<>(userHeaders); + + ResponseEntity userRes = restTemplate.exchange( + "https://openapi.naver.com/v1/nid/me", + HttpMethod.GET, + userReq, + Map.class + ); + + Map body = userRes.getBody(); + Map resp = (Map) body.get("response"); + + String id = (String) resp.get("id"); + String email = (String) resp.get("email"); + String name = (String) resp.get("name"); + String profileImage = (String) resp.get("profile_image"); + + return OAuthUserInfo.builder() + .id(id) + .email(email) + .name(name) + .profileImage(profileImage) + .provider("NAVER") + .build(); + } +} From 28366951601c99b7abc6335ac7665c316aa0d2f6 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Mon, 1 Sep 2025 13:59:59 +0900 Subject: [PATCH 202/527] feat: implement kakao social login --- .../scriptopia/demo/service/OAuthService.java | 5 +- .../scriptopia/demo/utils/KakaoClient.java | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/utils/KakaoClient.java diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java index 3970e1ce..f70a354d 100644 --- a/src/main/java/com/scriptopia/demo/service/OAuthService.java +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -15,6 +15,7 @@ import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.utils.GoogleClient; import com.scriptopia.demo.utils.JwtProvider; +import com.scriptopia.demo.utils.KakaoClient; import com.scriptopia.demo.utils.NaverClient; import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; @@ -39,7 +40,6 @@ @RequiredArgsConstructor public class OAuthService { - private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; @@ -50,6 +50,7 @@ public class OAuthService { private final RefreshTokenService refreshTokenService; // RefreshToken 관리 서비스 private final GoogleClient googleClient; private final NaverClient naverClient; + private final KakaoClient kakaoClient; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final OAuthProperties props; @@ -210,7 +211,7 @@ private OAuthUserInfo fetchUserInfoFromProvider(String provider, String code) { case "naver": return naverClient.getUserInfo(code); case "kakao": - // return kakaoClient.getUserInfo(code); + return kakaoClient.getUserInfo(code); default: throw new CustomException(ErrorCode.E_400_UNSUPPORTED_PROVIDER); } diff --git a/src/main/java/com/scriptopia/demo/utils/KakaoClient.java b/src/main/java/com/scriptopia/demo/utils/KakaoClient.java new file mode 100644 index 00000000..eba1cf9a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/utils/KakaoClient.java @@ -0,0 +1,72 @@ +package com.scriptopia.demo.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.config.OAuthProperties; +import com.scriptopia.demo.dto.oauth.OAuthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class KakaoClient { + + private final RestTemplate restTemplate; + private final OAuthProperties props; + private final ObjectMapper objectMapper; + + public OAuthUserInfo getUserInfo(String code) { + // 액세스 토큰 발급 + String tokenUrl = "https://kauth.kakao.com/oauth/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", props.getKakao().getClientId()); + params.add("client_secret", props.getKakao().getClientSecret()); + params.add("redirect_uri", props.getKakao().getRedirectUri()); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + ResponseEntity tokenRes = restTemplate.postForEntity(tokenUrl, request, Map.class); + + String accessToken = (String) tokenRes.getBody().get("access_token"); + + // 2. 사용자 정보 조회 + HttpHeaders userHeaders = new HttpHeaders(); + userHeaders.setBearerAuth(accessToken); + HttpEntity userReq = new HttpEntity<>(userHeaders); + + ResponseEntity userRes = restTemplate.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + userReq, + Map.class + ); + + Map body = userRes.getBody(); + String id = String.valueOf(body.get("id")); + + Map kakaoAccount = (Map) body.get("kakao_account"); + String email = (String) kakaoAccount.get("email"); + + Map profile = (Map) kakaoAccount.get("profile"); + String nickname = (String) profile.get("nickname"); + String profileImage = (String) profile.get("profile_image_url"); + + return OAuthUserInfo.builder() + .id(id) + .email(email) + .name(nickname) + .profileImage(profileImage) + .provider("KAKAO") + .build(); + } +} + From 4bc3c3e06ee092105796325d552590f861ce114a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 14:07:50 +0900 Subject: [PATCH 203/527] before merging --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + .../java/com/scriptopia/demo/service/GameSessionService.java | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index d0ae12c3..88ccf8c3 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -32,6 +32,7 @@ public enum ErrorCode { E_400_PIA_ITEM_DUPLICATE("E400019", "이미 존재하는 PIA 아이템 이름입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_REQUEST("E400020", "이름이나, 금액이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_GAME_ALREADY_IN_PROGRESS("E400021", "진행 중인 게임이 이미 존재합니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index ac14a1e9..97feb759 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -86,6 +86,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { Long itemId = Long.parseLong(request.getItemId()); userItem = userItemRepository.findByUserIdAndItemDefId(userId, itemId) .orElseThrow(() -> new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED)); + + } @@ -247,7 +249,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { } } -// 3. MongoSession 저장 + // 3. MongoSession 저장 mongoSession.setInventory(mongoInventory); mongoSession.setItemDef(mongoItemDefs); From e6af291d811a644572f3cf5d45c09d8bc939b112 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 14:15:07 +0900 Subject: [PATCH 204/527] feature/102-public-shared-game-detail-service add --- .../PublicSharedGameDetailResponse.java | 40 +++++++++++++++++++ .../repository/SharedGameScoreRepository.java | 4 ++ .../demo/service/SharedGameService.java | 32 +++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java new file mode 100644 index 00000000..d5bb16ba --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -0,0 +1,40 @@ +package com.scriptopia.demo.dto.sharedgame; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class PublicSharedGameDetailResponse { + private Long sharedGameId; + private String nickname; + private String thumbnailUrl; + private Long totalPlayed; + private String title; + private String worldView; + private String backgroundStory; + + @JsonFormat(shape = JsonFormat.Shape.STRING) + private LocalDateTime sharedAt; + private List tags; + private List topScores; + + @Data + public static class TagDto { + private String tagName; + + public TagDto(String tagName) { + this.tagName = tagName; + } + } + + @Data + public static class TopScoreDto { + private String nickname; + private Float score; + @JsonFormat(shape = JsonFormat.Shape.STRING) + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java index 565fd962..6eb3bd27 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java @@ -5,10 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; + public interface SharedGameScoreRepository extends JpaRepository { @Query("Select count(s) from SharedGameScore s where s.sharedGame.id = :sharedGameId") long countBySharedGameId(Long sharedGameId); @Query("select max(s.score) from SharedGameScore s where s.sharedGame.id = :sharedGameId") Long maxScoreBySharedGameId(Long sharedGameId); + + List findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index dbdea22c..82d17c91 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -2,8 +2,10 @@ import com.scriptopia.demo.domain.History; import com.scriptopia.demo.domain.SharedGame; +import com.scriptopia.demo.domain.SharedGameScore; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; +import com.scriptopia.demo.dto.sharedgame.PublicSharedGameDetailResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; @@ -84,4 +86,34 @@ public void deletesharedGame(Long id, Long sharedId) { sharedGameRepository.delete(game); } + + public ResponseEntity getDetailedSharedGame(Long sharedId) { + SharedGame game = sharedGameRepository.findById(sharedId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); + + List tagName = gameTagRepository.findTagNamesBySharedGameId(game.getId()); + + List score = sharedGameScoreRepository.findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(game.getId()); + + PublicSharedGameDetailResponse dto = new PublicSharedGameDetailResponse(); + dto.setSharedGameId(game.getId()); + dto.setNickname(game.getUser().getNickname()); + dto.setThumbnailUrl(game.getThumbnailUrl()); + dto.setTotalPlayed(game.getTotalPlayed()); + dto.setTitle(game.getTitle()); + dto.setWorldView(game.getWorldView()); + dto.setBackgroundStory(game.getBackgroundStory()); + dto.setSharedAt(game.getSharedAt()); + + dto.setTags(tagName.stream().map(PublicSharedGameDetailResponse.TagDto::new).toList()); + dto.setTopScores(score.stream().map(s ->{ + PublicSharedGameDetailResponse.TopScoreDto topScoreDto = new PublicSharedGameDetailResponse.TopScoreDto(); + topScoreDto.setNickname(s.getUser().getNickname()); + topScoreDto.setScore(s.getScore().floatValue()); + topScoreDto.setCreatedAt(s.getCreatedAt()); + return topScoreDto; + }).toList() ); + + return ResponseEntity.ok(dto); + } } From 23ada2d083c722cbb48e1d166e7ab382cd45a739 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 14:20:34 +0900 Subject: [PATCH 205/527] feat: add take item if createGameSession remainingUses -1 --- .../com/scriptopia/demo/service/GameSessionService.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 97feb759..5d01adfa 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -87,7 +87,9 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { userItem = userItemRepository.findByUserIdAndItemDefId(userId, itemId) .orElseThrow(() -> new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED)); - + if (userItem.getRemainingUses() <= 0) { + throw new CustomException(ErrorCode.E_400_ITEM_NO_USES_LEFT); + } } @@ -308,6 +310,10 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mysqlSession.setMongoId(savedMongo.getId()); gameSessionRepository.save(mysqlSession); + if (userItem != null) { + userItem.setRemainingUses(userItem.getRemainingUses() - 1); + userItemRepository.save(userItem); + } StartGameResponse startGameResponse = new StartGameResponse( "게임이 생성되었습니다.", From fb216adc2b7feea837c4d6c1885ffb61a25bd6ad Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 14:23:59 +0900 Subject: [PATCH 206/527] brfore test --- .../java/com/scriptopia/demo/exception/ErrorCode.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 3f673690..774d38f4 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -80,7 +80,13 @@ public enum ErrorCode { //500 Internal Server Error E_500("E_500000", "예상치 못한 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), E_500_TOKEN_HASHING_FAILED("E_500001","리프레쉬 토큰 해싱에 실패했습니다.",HttpStatus.INTERNAL_SERVER_ERROR), - E_500_EXTERNAL_API_ERROR("E500001", "외부 게임 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + E_500_EXTERNAL_API_ERROR("E500002", "외부 게임 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_DATABASE_ERROR("E500003", "데이터베이스 처리 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_TOKEN_CREATION_FAILED("E500004", "인증 토큰 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_TOKEN_STORAGE_FAILED("E500005", "리프레시 토큰 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + //502 BAD_GATEWAY + E_502_OAUTH_SERVER_ERROR("E502001", "소셜 로그인 서버와의 통신에 실패했습니다.", HttpStatus.BAD_GATEWAY); private final String code; From ce6300cf649cfcb02fb63b68aa946d832abb0790 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 14:43:13 +0900 Subject: [PATCH 207/527] complete test --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0264f1b7..c74a8648 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: show_sql: true From c2803c0db7f4ed8b2b80c38bc2f3ab039320fee7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 14:45:36 +0900 Subject: [PATCH 208/527] feat: add E_400_ITEM_NO_USES_LEFT if userItem.getRemainingUses() <= 0 to service --- src/main/java/com/scriptopia/demo/service/AuctionService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index c9d07aab..87e020a7 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -54,6 +54,10 @@ public String createAuction(AuctionRequest requestDto, Long userId) { throw new CustomException(ErrorCode.E_400_ITEM_NOT_TRADE_ABLE); } + if (userItem.getRemainingUses() <= 0){ + throw new CustomException(ErrorCode.E_400_ITEM_NO_USES_LEFT); + } + // 이미 경매장에 등록되어 있는지 확인 if (auctionRepository.existsByUserItem(userItem)) { throw new CustomException(ErrorCode.E_400_ITEM_ALREADY_REGISTERED); From db41e1dc8e559a56c8b1a13fe311f1902ea125d3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:27:23 +0900 Subject: [PATCH 209/527] refactor: change endPointName to AuctionController HistoryController --- .../java/com/scriptopia/demo/config/DataLoaderConfig.java | 4 ++-- .../com/scriptopia/demo/controller/AuctionController.java | 2 +- .../com/scriptopia/demo/controller/HistoryController.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index 43721451..4671b545 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -20,14 +20,14 @@ public class DataLoaderConfig { @Bean public ApplicationRunner dataLoader() { return args -> { - // ✅ ItemGradeDef 기본 데이터 + // ItemGradeDef 기본 데이터 saveItemGradeIfNotExists(Grade.COMMON, 1.0, 100L); saveItemGradeIfNotExists(Grade.UNCOMMON, 1.0, 200L); saveItemGradeIfNotExists(Grade.RARE, 1.0, 500L); saveItemGradeIfNotExists(Grade.EPIC, 1.0, 1000L); saveItemGradeIfNotExists(Grade.LEGENDARY, 1.0, 2000L); - // ✅ EffectGradeDef 기본 데이터 + // EffectGradeDef 기본 데이터 saveEffectGradeIfNotExists(Grade.COMMON, 100L, 0.1); saveEffectGradeIfNotExists(Grade.UNCOMMON, 200L, 0.15); saveEffectGradeIfNotExists(Grade.RARE, 500L, 0.2); diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 9700ec74..3787ad9b 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -34,7 +34,7 @@ public ResponseEntity getTrades( } - @PostMapping("/user/{auctionId}/purchase") + @PostMapping("/user/trades/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, Authentication authentication) { diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java index 8824d6b8..c05920a0 100644 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/HistoryController.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/users/games") +@RequestMapping("/users/my-page") @RequiredArgsConstructor public class HistoryController { private final HistoryService historyService; From 7d9e5b79aa6273db782c4c7d48dc650a586f8611 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:34:05 +0900 Subject: [PATCH 210/527] refactor: move historyController method to gameSessionController delete historyController --- .../controller/GameSessionController.java | 21 ++++++++++++ .../demo/controller/HistoryController.java | 33 ------------------- .../demo/controller/PiaShopController.java | 4 +-- .../controller/UserHistoryController.java | 3 +- 4 files changed, 24 insertions(+), 37 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/controller/HistoryController.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 608cd1a2..945723ef 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; import com.scriptopia.demo.service.GameSessionService; +import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -13,6 +14,7 @@ @RequiredArgsConstructor public class GameSessionController { private final GameSessionService gameSessionService; + private final HistoryService historyService; @PostMapping("/{sessionId}/exit") public ResponseEntity createGameSession(Authentication authentication, @PathVariable String sessionId) { @@ -52,4 +54,23 @@ public ResponseEntity startNewGame( } + /** + * 현재는 userId, sessionId를 통해 저장하는데 + * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 + */ + @PostMapping("/{gameId}/history") + public ResponseEntity addHistory(@PathVariable String sid, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return historyService.createHistory(userId, sid); + } + + /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ + @PostMapping("/history/seed") + public ResponseEntity seed(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return historyService.seedDummySession(userId); + } + } diff --git a/src/main/java/com/scriptopia/demo/controller/HistoryController.java b/src/main/java/com/scriptopia/demo/controller/HistoryController.java deleted file mode 100644 index c05920a0..00000000 --- a/src/main/java/com/scriptopia/demo/controller/HistoryController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.service.HistoryService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/users/my-page") -@RequiredArgsConstructor -public class HistoryController { - private final HistoryService historyService; - - /** - * 현재는 userId, sessionId를 통해 저장하는데 - * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 - */ - @PostMapping("/{sid}/history") - public ResponseEntity addHistory(@PathVariable String sid, Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return historyService.createHistory(userId, sid); - } - - /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ - @PostMapping("/history/seed") - public ResponseEntity seed(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return historyService.seedDummySession(userId); - } -} diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index d3f45d3b..0b5fb6bc 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -18,7 +18,7 @@ public class PiaShopController { private final PiaShopService piaShopService; // admin → public 으로 테스트용 변경했음 나중에 수정 바람 - @PostMapping("/public/items/pia") + @PostMapping("/public/shops/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { piaShopService.createPiaItem(request); return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); @@ -26,7 +26,7 @@ public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) // admin → public 으로 테스트용 변경했음 나중에 수정 바람 - @PutMapping("/public/items/pia/{itemId}") + @PutMapping("/public/shops/items/pia/{itemId}") public ResponseEntity updatePiaItem( @PathVariable String itemId, @RequestBody PiaItemUpdateRequest requestDto) { diff --git a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java index 78919da0..fa688dd3 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java @@ -11,12 +11,11 @@ import java.util.List; @RestController -@RequestMapping("/users") @RequiredArgsConstructor public class UserHistoryController { private final HistoryService historyService; - @GetMapping("/history") + @GetMapping("/user/my-page/history") public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "10") int size, Authentication authentication) { From c432602f54f562b8e878538bde95b6b1ebb23ca6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:37:42 +0900 Subject: [PATCH 211/527] refactor: authControllerName --- .../demo/controller/AuthController.java | 70 ++++++++++--------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 1d94d2ee..57783c2d 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -31,28 +31,19 @@ public class AuthController { private static final String COOKIE_SAMESITE = "None"; - @PatchMapping("public/auth/password/reset") - public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { - localAccountService.resetPassword(request.getToken(), request.getNewPassword()); - - return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); - } - - @PostMapping("/public/auth/password/send-link") - public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ - localAccountService.sendResetPasswordMail(request.getEmail()); - - return ResponseEntity.ok("비밀번호 초기화 링크를 전송했습니다."); + @PostMapping("/user/auth/logout") + public ResponseEntity logout( + @CookieValue(name = RT_COOKIE, required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken != null && !refreshToken.isBlank()) { + refreshTokenService.logout(refreshToken); + } + response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); + return ResponseEntity.ok("로그아웃 되었습니다."); } - @PostMapping("/public/auth/verify-email") - public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { - - localAccountService.verifyEmail(request); - - return ResponseEntity.ok("사용 가능한 이메일입니다."); - } @PostMapping("/public/auth/login") public ResponseEntity login( @@ -64,18 +55,6 @@ public ResponseEntity login( return ResponseEntity.ok(localAccountService.login(req, request, response)); } - @PostMapping("/user/auth/logout") - public ResponseEntity logout( - @CookieValue(name = RT_COOKIE, required = false) String refreshToken, - HttpServletResponse response - ) { - if (refreshToken != null && !refreshToken.isBlank()) { - refreshTokenService.logout(refreshToken); - } - response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); - return ResponseEntity.ok("로그아웃 되었습니다."); - } - @PostMapping("/public/auth/register") public ResponseEntity register( @RequestBody @Valid RegisterRequest request @@ -84,13 +63,22 @@ public ResponseEntity register( return ResponseEntity.ok("회원가입에 성공했습니다."); } - @PostMapping("/public/auth/send-code") + @PostMapping("/public/auth/email/verify") + public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + + localAccountService.verifyEmail(request); + + return ResponseEntity.ok("사용 가능한 이메일입니다."); + } + + + @PostMapping("/public/auth/email/code/send") public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { localAccountService.sendVerificationCode(request.getEmail()); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } - @PostMapping("/public/auth/verify-code") + @PostMapping("/public/auth/email/code/verify") public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { localAccountService.verifyCode(request.getEmail(), request.getCode()); return ResponseEntity.ok("이메일 인증이 완료되었습니다."); @@ -98,6 +86,22 @@ public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest r } + @PostMapping("/public/auth/password/reset-link/send") + public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ + + localAccountService.sendResetPasswordMail(request.getEmail()); + + return ResponseEntity.ok("비밀번호 초기화 링크를 전송했습니다."); + } + + + @PatchMapping("public/auth/password/reset") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + localAccountService.resetPassword(request.getToken(), request.getNewPassword()); + + return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); + } + @PatchMapping("/user/auth/password/change") public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, From bb8552bee231436f3ba0421242bd060dc20718ef Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:38:44 +0900 Subject: [PATCH 212/527] change OAuthController --- .../scriptopia/demo/controller/OAuthController.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index 4d8720de..00f5499e 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -18,6 +18,11 @@ public class OAuthController { private final OAuthService oAuthService; + @GetMapping("/authorize") + public ResponseEntity getAuthorizationUrl(@RequestParam("provider") String provider) { + return ResponseEntity.ok(oAuthService.buildAuthorizationUrl(provider)); + } + @GetMapping("/{provider}") public ResponseEntity login( @PathVariable("provider") String provider, @@ -39,8 +44,5 @@ public ResponseEntity signup( return ResponseEntity.ok(result); } - @GetMapping("/authorize") - public ResponseEntity getAuthorizationUrl(@RequestParam("provider") String provider) { - return ResponseEntity.ok(oAuthService.buildAuthorizationUrl(provider)); - } + } From a9675cdd0a7c1d4af634e41226da3a43488cc38c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:44:23 +0900 Subject: [PATCH 213/527] refactor: change AuctionController --- .../demo/controller/AuctionController.java | 53 ++++++++----------- .../demo/controller/OAuthController.java | 1 - 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 3787ad9b..60fa41b0 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -15,15 +15,6 @@ public class AuctionController { private final AuctionService auctionService; - @PostMapping("/user/trades") - public ResponseEntity createAuction(@RequestBody AuctionRequest dto, - Authentication authentication ){ - - Long userId = Long.valueOf(authentication.getName()); - return ResponseEntity.ok(auctionService.createAuction(dto, userId)); - } - - @GetMapping("/public/trades") public ResponseEntity getTrades( @RequestBody TradeFilterRequest requestDto) { @@ -33,7 +24,6 @@ public ResponseEntity getTrades( } - @PostMapping("/user/trades/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, @@ -45,53 +35,56 @@ public ResponseEntity purchaseItem( return ResponseEntity.ok(result); } - @PatchMapping("/user/trades/{settlementId}/confirm") - public ResponseEntity confirmItem( - @PathVariable String settlementId, + @GetMapping("/user/trades/me") + public ResponseEntity mySaleItems( + @RequestBody MySaleItemRequest requestDto, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - String result = auctionService.confirmItem(settlementId, userId); + MySaleItemResponse result = auctionService.getMySaleItems(userId, requestDto); return ResponseEntity.ok(result); } + @PostMapping("/user/trades") + public ResponseEntity createAuction(@RequestBody AuctionRequest dto, + Authentication authentication ){ + Long userId = Long.valueOf(authentication.getName()); + return ResponseEntity.ok(auctionService.createAuction(dto, userId)); + } - @GetMapping("/user/trades/me/history") - public ResponseEntity settlementHistory( - @RequestBody SettlementHistoryRequest requestDto, + @DeleteMapping("/user/trades/{auctionId}") + public ResponseEntity cancelMySaleItem( + @PathVariable String auctionId, Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - SettlementHistoryResponse result = auctionService.settlementHistory(userId, requestDto); + String result = auctionService.cancelMySaleItem(userId, auctionId); return ResponseEntity.ok(result); } - @GetMapping("/user/trades/me") - public ResponseEntity mySaleItems( - @RequestBody MySaleItemRequest requestDto, + @GetMapping("/user/trades/me/history") + public ResponseEntity settlementHistory( + @RequestBody SettlementHistoryRequest requestDto, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - MySaleItemResponse result = auctionService.getMySaleItems(userId, requestDto); + SettlementHistoryResponse result = auctionService.settlementHistory(userId, requestDto); return ResponseEntity.ok(result); } - - - @DeleteMapping("/user/trades/{auctionId}") - public ResponseEntity cancelMySaleItem( - @PathVariable String auctionId, + @PatchMapping("/user/trades/{settlementId}/confirm") + public ResponseEntity confirmItem( + @PathVariable String settlementId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); - String result = auctionService.cancelMySaleItem(userId, auctionId); + String result = auctionService.confirmItem(settlementId, userId); return ResponseEntity.ok(result); } - } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index 00f5499e..29db3b1e 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -44,5 +44,4 @@ public ResponseEntity signup( return ResponseEntity.ok(result); } - } From 7b860d49180f7ae4611663a2060f51102275ca9f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:46:43 +0900 Subject: [PATCH 214/527] refactor: change UesrHistoryController to MyPageController --- .../{UserHistoryController.java => MyPageController.java} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename src/main/java/com/scriptopia/demo/controller/{UserHistoryController.java => MyPageController.java} (91%) diff --git a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java similarity index 91% rename from src/main/java/com/scriptopia/demo/controller/UserHistoryController.java rename to src/main/java/com/scriptopia/demo/controller/MyPageController.java index fa688dd3..3607b921 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserHistoryController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -1,7 +1,6 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.history.HistoryPageResponse; -import com.scriptopia.demo.dto.history.HistoryResponse; import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -12,7 +11,7 @@ @RestController @RequiredArgsConstructor -public class UserHistoryController { +public class MyPageController { private final HistoryService historyService; @GetMapping("/user/my-page/history") From 3699ea00ea33f2d7197076729020700009cbf480 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:57:41 +0900 Subject: [PATCH 215/527] refactor: delete sharedGameController move sharedGameController method --- .../demo/controller/MyPageController.java | 25 +++++++++++++ .../demo/controller/SharedGameController.java | 37 ------------------- 2 files changed, 25 insertions(+), 37 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/controller/SharedGameController.java diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index 3607b921..bbb7e439 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.service.HistoryService; +import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -13,6 +14,7 @@ @RequiredArgsConstructor public class MyPageController { private final HistoryService historyService; + private final SharedGameService sharedGameService; @GetMapping("/user/my-page/history") public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, @@ -22,4 +24,27 @@ public ResponseEntity> getHistory(@RequestParam(requir return historyService.fetchMyHisotry(userId, lastId, size); } + + @GetMapping("/user/my-page/games/shared") + public ResponseEntity getMySharedGames(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.getMySharedGames(userId); + } + + @PostMapping("/user/my-page/share/{hid}") + public ResponseEntity share(Authentication authentication, @PathVariable Long hid) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.saveSharedGame(userId, hid); + } + + @DeleteMapping("/user/my-page/share/{gameid}") + public ResponseEntity delete(Authentication authentication, @PathVariable Long gameid) { + Long userId = Long.valueOf(authentication.getName()); + + sharedGameService.deletesharedGame(userId, gameid); + + return ResponseEntity.ok("게임이 삭제되었습니다."); + } } diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java deleted file mode 100644 index 6032aa4d..00000000 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.service.SharedGameService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/users") -@RequiredArgsConstructor -public class SharedGameController { - private final SharedGameService sharedGameService; - - @PostMapping("/share/{hid}") - public ResponseEntity share(Authentication authentication, @PathVariable Long hid) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.saveSharedGame(userId, hid); - } - - @GetMapping("/games/shared") - public ResponseEntity getMySharedGames(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.getMySharedGames(userId); - } - - @DeleteMapping("/share/{gameid}") - public ResponseEntity delete(Authentication authentication, @PathVariable Long gameid) { - Long userId = Long.valueOf(authentication.getName()); - - sharedGameService.deletesharedGame(userId, gameid); - - return ResponseEntity.ok("게임이 삭제되었습니다."); - } -} From 666f56cdcb5bf88f70cdef5e29baa5a119aff7b9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Mon, 1 Sep 2025 15:59:38 +0900 Subject: [PATCH 216/527] refactor: change ControllerName SharedGameFavoriteController to SearchController --- .../com/scriptopia/demo/controller/PiaShopController.java | 2 -- ...redGameFavoriteController.java => SearchController.java} | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) rename src/main/java/com/scriptopia/demo/controller/{SharedGameFavoriteController.java => SearchController.java} (81%) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 0b5fb6bc..375b9125 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -36,8 +36,6 @@ public ResponseEntity updatePiaItem( return ResponseEntity.ok(result); } - - @GetMapping("/user/shops/pia/items") public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java b/src/main/java/com/scriptopia/demo/controller/SearchController.java similarity index 81% rename from src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java rename to src/main/java/com/scriptopia/demo/controller/SearchController.java index 05735575..ceb3ce8d 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameFavoriteController.java +++ b/src/main/java/com/scriptopia/demo/controller/SearchController.java @@ -6,16 +6,14 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/users/games") @RequiredArgsConstructor -public class SharedGameFavoriteController { +public class SearchController { private final SharedGameFavoriteService sharedGameFavoriteService; - @PostMapping("/shared/{sharedGameId}/like") + @PostMapping("/users/games/shared/{sharedGameId}/like") public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); From c31a1758e4b8bb53368ef39a67ef6f2290f328fb Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 16:17:30 +0900 Subject: [PATCH 217/527] feature/102-public-shared-game-detail-controller add --- .../controller/PublicSharedGameController.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java new file mode 100644 index 00000000..b76b931d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.service.SharedGameService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/public/games/shared") +@RequiredArgsConstructor +public class PublicSharedGameController { + private final SharedGameService sharedGameService; + + @GetMapping("/{sharedGameId}") + public ResponseEntity getSharedGameDetail(@PathVariable Long sharedGameId) { + return sharedGameService.getDetailedSharedGame(sharedGameId); + } +} From af895066d70d4426ba1aac389874b5330d6d4d7d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 17:15:17 +0900 Subject: [PATCH 218/527] feature/109-public-shared-game-tag-get add service, controller --- .../controller/PublicSharedGameController.java | 5 +++++ .../dto/sharedgame/PublicTagDefResponse.java | 11 +++++++++++ .../demo/service/SharedGameService.java | 17 +++++++++++++---- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java index b76b931d..90a8cc54 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -15,4 +15,9 @@ public class PublicSharedGameController { public ResponseEntity getSharedGameDetail(@PathVariable Long sharedGameId) { return sharedGameService.getDetailedSharedGame(sharedGameId); } + + @GetMapping("/tags") + public ResponseEntity getSharedGameTags() { + return sharedGameService.getTag(); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java new file mode 100644 index 00000000..1a35d3a2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java @@ -0,0 +1,11 @@ +package com.scriptopia.demo.dto.sharedgame; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PublicTagDefResponse { + private Long id; + private String tagName; +} diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 82d17c91..9ca1ae57 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -1,11 +1,9 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.History; -import com.scriptopia.demo.domain.SharedGame; -import com.scriptopia.demo.domain.SharedGameScore; -import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameDetailResponse; +import com.scriptopia.demo.dto.sharedgame.PublicTagDefResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; @@ -25,6 +23,7 @@ public class SharedGameService { private final SharedGameScoreRepository sharedGameScoreRepository; private final SharedGameFavoriteRepository sharedGameFavoriteRepository; private final GameTagRepository gameTagRepository; + private final TagDefRepository tagDefRepository; @Transactional public ResponseEntity saveSharedGame(Long Id, Long historyId) { @@ -116,4 +115,14 @@ public ResponseEntity getDetailedSharedGame(Long sharedId) { return ResponseEntity.ok(dto); } + + public ResponseEntity getTag() { + List tag = tagDefRepository.findAll(); + + List dtoList = tag.stream() + .map(t -> new PublicTagDefResponse(t.getId(), t.getTagName())) + .toList(); + + return ResponseEntity.ok(dtoList); + } } From 325e0547d3d236931281cd7be6fdc4e897feadc4 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 18:24:08 +0900 Subject: [PATCH 219/527] feature/111-session get and api move --- .../demo/controller/GameSessionController.java | 16 ++++------------ .../demo/controller/MyPageController.java | 16 ++++++++++++++++ .../demo/service/GameSessionService.java | 1 - 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 945723ef..98c2207c 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -16,23 +16,15 @@ public class GameSessionController { private final GameSessionService gameSessionService; private final HistoryService historyService; - @PostMapping("/{sessionId}/exit") - public ResponseEntity createGameSession(Authentication authentication, @PathVariable String sessionId) { + @PostMapping("/{gameId}/exit") + public ResponseEntity createGameSession(Authentication authentication, @PathVariable String gameId) { // 게임 세션 정보 저장 Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.saveGameSession(userId, sessionId); + return gameSessionService.saveGameSession(userId, gameId); } - // 정보 불러오기 - @GetMapping - public ResponseEntity loadGameSession(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return gameSessionService.getGameSession(userId); - } - - @DeleteMapping("/{sessionId}") + @DeleteMapping("/{gameId}") public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index bbb7e439..e9da0759 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.history.HistoryPageResponse; +import com.scriptopia.demo.service.GameSessionService; import com.scriptopia.demo.service.HistoryService; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ public class MyPageController { private final HistoryService historyService; private final SharedGameService sharedGameService; + private final GameSessionService gameSessionService; @GetMapping("/user/my-page/history") public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, @@ -47,4 +49,18 @@ public ResponseEntity delete(Authentication authentication, @PathVariable Lon return ResponseEntity.ok("게임이 삭제되었습니다."); } + + @GetMapping("/user/my-page/game") + public ResponseEntity loadGameSession(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return gameSessionService.getGameSession(userId); + } + + @DeleteMapping("/user/my-page/game/{gameId}") + public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String gameId) { + Long userId = Long.valueOf(authentication.getName()); + + return gameSessionService.deleteGameSession(userId, gameId); + } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 5d01adfa..dc15854a 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -28,7 +28,6 @@ public class GameSessionService { private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; - private final ItemDefRepository itemDefRepository; public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) From b5a3369b0ec160a34b08b625d8039710c6f15c61 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 19:57:21 +0900 Subject: [PATCH 220/527] feature/111 errorCode modify and characterImg service add --- .../scriptopia/demo/exception/ErrorCode.java | 4 +- .../demo/service/UserCharacterImgService.java | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 774d38f4..62551b9c 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -35,7 +35,8 @@ public enum ErrorCode { E_400_INVALID_SOCIAL_LOGIN_CODE("E400022", "유효하지 않거나 만료된 인증 코드입니다.", HttpStatus.BAD_REQUEST), E_400_NO_EMAIL("E400023", "소셜 계정에서 이메일 정보를 제공하지 않았습니다.", HttpStatus.BAD_REQUEST), E_400_UNSUPPORTED_PROVIDER("E400024", "지원하지 않는 소셜 로그인 공급자입니다.", HttpStatus.BAD_REQUEST), - E_400_ITEM_NO_USES_LEFT("E400024", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), + E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), + E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), @@ -84,6 +85,7 @@ public enum ErrorCode { E_500_DATABASE_ERROR("E500003", "데이터베이스 처리 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), E_500_TOKEN_CREATION_FAILED("E500004", "인증 토큰 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), E_500_TOKEN_STORAGE_FAILED("E500005", "리프레시 토큰 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + E_500_File_SAVED_FAILED("E500006", "파일 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), //502 BAD_GATEWAY E_502_OAUTH_SERVER_ERROR("E502001", "소셜 로그인 서버와의 통신에 실패했습니다.", HttpStatus.BAD_GATEWAY); diff --git a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java new file mode 100644 index 00000000..4d90bec2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java @@ -0,0 +1,57 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserCharacterImg; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.UserCharacterImgRepository; +import com.scriptopia.demo.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserCharacterImgService { + private final UserCharacterImgRepository userCharacterImgRepository; + private final UserRepository userRepository; + + @Transactional + public ResponseEntity saveCharacterImg(Long userId, MultipartFile file) { + if(file.isEmpty()) { + throw new CustomException(ErrorCode.E_400_EMPTY_FILE); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + try { + String tmpDir = System.getProperty("java.io.tmpdir"); + + String originalFilename = file.getOriginalFilename(); + String ext = ""; + if (originalFilename != null && originalFilename.contains(".")) { + ext = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String saveName = UUID.randomUUID() + ext; + + File savefile = new File(tmpDir, saveName); + file.transferTo(savefile); + + UserCharacterImg userCharacterImg = new UserCharacterImg(); + userCharacterImg.setUser(user); + userCharacterImg.setImgUrl(savefile.getAbsolutePath()); + + userCharacterImgRepository.save(userCharacterImg); + + return ResponseEntity.ok(userCharacterImg.getImgUrl()); + } catch (Exception e) { + throw new CustomException(ErrorCode.E_500_File_SAVED_FAILED); + } + } +} From 049d2873688ae471572d48228c3e25f5a3c9ca01 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 20:12:27 +0900 Subject: [PATCH 221/527] feature/114-ai-img-save controller add --- .../UserCharacterImgController.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java diff --git a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java new file mode 100644 index 00000000..824d1c37 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java @@ -0,0 +1,25 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.service.UserCharacterImgService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/user/img") +@RequiredArgsConstructor +public class UserCharacterImgController { + private final UserCharacterImgService userCharacterImgService; + + @PostMapping("/save") + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.saveCharacterImg(userId, file); + } +} From 2b227574bee2a3a8fa8715139fa44fdd1855f066 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 22:34:38 +0900 Subject: [PATCH 222/527] feature/49-shared-game CursorPage dto add --- .../java/com/scriptopia/demo/dto/sharedgame/CursorPage.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java new file mode 100644 index 00000000..3608d0da --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java @@ -0,0 +1,5 @@ +package com.scriptopia.demo.dto.sharedgame; + +import java.util.List; + +public record CursorPage(List items, Long nextCursor, boolean hasNext) {} \ No newline at end of file From 143c4e9baf622de35bf02d66cc82971242598296 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 1 Sep 2025 22:44:15 +0900 Subject: [PATCH 223/527] feature/49-shared-game-public-get add controller, service, dto --- .../PublicSharedGameController.java | 15 ++++++ .../dto/sharedgame/MySharedGameResponse.java | 2 +- .../sharedgame/PublicSharedGameResponse.java | 21 ++++++++ .../demo/dto/sharedgame/TagDto.java | 11 ++++ .../demo/repository/GameTagRepository.java | 10 ++++ .../demo/repository/SharedGameRepository.java | 45 +++++++++++++++- .../demo/service/SharedGameService.java | 51 +++++++++++++++++-- 7 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/TagDto.java diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java index 90a8cc54..90b55908 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -1,10 +1,15 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.sharedgame.CursorPage; +import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/public/games/shared") @RequiredArgsConstructor @@ -20,4 +25,14 @@ public ResponseEntity getSharedGameDetail(@PathVariable Long sharedGameId) { public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); } + + @GetMapping("/check") + public ResponseEntity> getPublicSharedGames(Authentication authentication, + @RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) List tagIds, + @RequestParam(required = false) String q) { + Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); + return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, q); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java index e16cce7e..b48dc454 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java @@ -24,4 +24,4 @@ public TagDto(String tagName) { this.tagName = tagName; } } -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java new file mode 100644 index 00000000..f847e3b2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.dto.sharedgame; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class PublicSharedGameResponse { + private Long sharedGameId; + private String thumbnailUrl; + private boolean isLiked; + private Long likeCount; + private Long totalPlayCount; + private String title; + private Long topScore; + private LocalDateTime sharedAt; + + private List tags; + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/TagDto.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/TagDto.java new file mode 100644 index 00000000..a819c668 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/TagDto.java @@ -0,0 +1,11 @@ +package com.scriptopia.demo.dto.sharedgame; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TagDto { + private Long tagId; + private String tagName; +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java index 84aef9be..6910cde9 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.domain.GameTag; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; +import com.scriptopia.demo.dto.sharedgame.TagDto; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -18,4 +19,13 @@ public interface GameTagRepository extends JpaRepository { @Query("select new com.scriptopia.demo.dto.TagDef.TagDefCreateRequest(gt.tagDef.tagName) " + "from GameTag gt where gt.sharedGame.id = :sharedGameId") List findTagsBySharedGameId(@Param("sharedGameId") Long sharedGameId); + + @Query(""" + select new com.scriptopia.demo.dto.sharedgame.TagDto(td.id, td.tagName) + from GameTag gt + join gt.tagDef td + where gt.sharedGame.id = :sharedGameId + order by td.tagName asc +""") + List findTagDtosBySharedGameId(@Param("sharedGameId") Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index ce2f7177..ab82c0ef 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -2,11 +2,54 @@ import com.scriptopia.demo.domain.PiaItem; import com.scriptopia.demo.domain.SharedGame; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface SharedGameRepository extends JpaRepository { List findAllByUserId(Long userId); -} + // 기본(전체) + @Query(""" + select g from SharedGame g + where (:lastId is null or g.id < :lastId) + order by g.id desc + """) + Page pageAll(@Param("lastId") Long lastId, Pageable pageable); + + // 🔎 검색 전용 (태그 무시) + @Query(""" + select g from SharedGame g + where (:lastId is null or g.id < :lastId) + and ( + lower(g.title) like lower(concat('%', :q, '%')) + or lower(g.worldView) like lower(concat('%', :q, '%')) + or lower(g.backgroundStory) like lower(concat('%', :q, '%')) + ) + order by g.id desc + """) + Page pageSearchOnly(@Param("lastId") Long lastId, + @Param("q") String q, + Pageable pageable); + + + // 🏷 태그 ALL 전용 (검색 없음) + @Query(""" + select g from SharedGame g + join GameTag gt on gt.sharedGame = g + join TagDef td on td = gt.tagDef + where (:lastId is null or g.id < :lastId) + and td.id in :tagIds + group by g.id + having count(distinct td.id) = :tagCount + order by g.id desc + """) + Page pageByAllTagsOnly(@Param("lastId") Long lastId, + @Param("tagIds") List tagIds, + @Param("tagCount") long tagCount, + Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 9ca1ae57..a4eb7f43 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -1,13 +1,13 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.sharedgame.MySharedGameResponse; -import com.scriptopia.demo.dto.sharedgame.PublicSharedGameDetailResponse; -import com.scriptopia.demo.dto.sharedgame.PublicTagDefResponse; +import com.scriptopia.demo.dto.sharedgame.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -125,4 +125,49 @@ public ResponseEntity getTag() { return ResponseEntity.ok(dtoList); } + + @Transactional(readOnly = true) + public ResponseEntity> getPublicSharedGames(Long userId, Long lastId, int size, + List tagIds, String q) { + + PageRequest pr = PageRequest.of(0, size); + Page page; + + boolean hasQ = q != null && q.isBlank(); + boolean hasTags = tagIds != null && !tagIds.isEmpty(); + + if(hasQ) { + page = sharedGameRepository.pageSearchOnly(lastId, q.trim(), pr); + } + else if(hasTags) { + page = sharedGameRepository.pageByAllTagsOnly(lastId, tagIds, tagIds.size(), pr); + } + else { + page = sharedGameRepository.pageAll(lastId, pr); + } + + var items = page.getContent().stream().map(g -> { + var dto = new PublicSharedGameResponse(); + dto.setSharedGameId(g.getId()); + dto.setThumbnailUrl(g.getThumbnailUrl()); + dto.setTitle(g.getTitle()); + dto.setTopScore(sharedGameScoreRepository.maxScoreBySharedGameId(g.getId())); + dto.setSharedAt(g.getSharedAt()); + + dto.setTotalPlayCount(sharedGameScoreRepository.countBySharedGameId(g.getId())); + dto.setLikeCount(sharedGameFavoriteRepository.countBySharedGameId(g.getId())); + + if(userId != null) { + dto.setLiked(sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, g.getId())); + } + + List tags = gameTagRepository.findTagDtosBySharedGameId(g.getId()); + dto.setTags(tags); + + return dto; + }).toList(); + + Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getSharedGameId(); + return ResponseEntity.ok(new CursorPage<>(items, nextCursor, page.hasNext())); + } } From 0febe0c509e2e50753fbf2333ef432b2ad8d6fef Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:21:33 +0900 Subject: [PATCH 224/527] feat: rename entity MainStat to Stat --- .../java/com/scriptopia/demo/domain/ItemDef.java | 2 +- .../demo/domain/{MainStat.java => Stat.java} | 2 +- .../scriptopia/demo/domain/mongo/ChoiceMongo.java | 4 ++-- .../scriptopia/demo/domain/mongo/ItemDefMongo.java | 6 ++---- .../demo/domain/mongo/PlayerInfoMongo.java | 13 ++++++------- 5 files changed, 12 insertions(+), 15 deletions(-) rename src/main/java/com/scriptopia/demo/domain/{MainStat.java => Stat.java} (92%) diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index b1af6513..1d1a802c 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -36,7 +36,7 @@ public class ItemDef { private Integer luck; @Enumerated(EnumType.STRING) - private MainStat mainStat; + private Stat stat; private LocalDateTime createdAt; diff --git a/src/main/java/com/scriptopia/demo/domain/MainStat.java b/src/main/java/com/scriptopia/demo/domain/Stat.java similarity index 92% rename from src/main/java/com/scriptopia/demo/domain/MainStat.java rename to src/main/java/com/scriptopia/demo/domain/Stat.java index 4a41eaaa..05e08c3e 100644 --- a/src/main/java/com/scriptopia/demo/domain/MainStat.java +++ b/src/main/java/com/scriptopia/demo/domain/Stat.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; -public enum MainStat { +public enum Stat { @JsonProperty("intelligence") INTELLIGENCE, @JsonProperty("strength") diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index 7ba59ede..b616766f 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.ChoiceResultType; -import com.scriptopia.demo.domain.MainStat; +import com.scriptopia.demo.domain.Stat; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -14,7 +14,7 @@ @NoArgsConstructor public class ChoiceMongo { private String detail; - private MainStat stats; // strength, agility, intelligence, luck + private Stat stats; // strength, agility, intelligence, luck private Integer probability; private ChoiceResultType resultType; // battle, reward, shop, none } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index 11a6a417..32bdc9c9 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -1,9 +1,8 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemCategory; import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; +import com.scriptopia.demo.domain.Stat; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -27,8 +26,7 @@ public class ItemDefMongo { private Integer agility; private Integer intelligence; private Integer luck; - private MainStat mainStat; // strength, agility, intelligence, luck - private Integer weight; + private Stat mainStat; // strength, agility, intelligence, luck private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY private Long price; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java index 35964398..c6bc2af6 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java @@ -13,13 +13,12 @@ public class PlayerInfoMongo { private String name; private Integer life; private Integer level; + private Integer healthPoint; // 난수 private Integer experiencePoint; - private Integer combatPoint; - private Integer healthPoint; private String trait; - private Integer strength; - private Integer agility; - private Integer intelligence; - private Integer luck; - private Integer gold; + private Integer strength; // 난수 + private Integer agility; // 난수 + private Integer intelligence; // 난수 + private Integer luck; // 난수 + private Integer gold; // 난수 } From 3143727df305c4a596a5a22ce7669bf79ab29dc6 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:24:31 +0900 Subject: [PATCH 225/527] feat:update variable MainStat to Stat --- .../demo/dto/auction/TradeFilterRequest.java | 4 +- .../dto/gamesession/ExternalGameResponse.java | 38 ++----------------- .../demo/dto/items/ItemDefRequest.java | 5 +-- .../demo/repository/AuctionRepository.java | 4 +- .../demo/service/AuctionService.java | 4 +- .../demo/service/ItemDefService.java | 4 +- 6 files changed, 13 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java index 7c739f44..edb1b6f9 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/TradeFilterRequest.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; +import com.scriptopia.demo.domain.Stat; import lombok.Data; import java.util.List; @@ -19,5 +19,5 @@ public class TradeFilterRequest { private Long maxPrice; // 최대 가격 (nullable) private Grade grade; // 아이템 등급 (nullable) private List effectGrades; // 아이템 효과 등급 필터 (nullable) - private MainStat mainStat; // 주 스탯 (nullable) + private Stat stat; // 주 스탯 (nullable) } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index d97414d1..9df8dda7 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -1,13 +1,11 @@ package com.scriptopia.demo.dto.gamesession; import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; +import com.scriptopia.demo.domain.Stat; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; import java.util.List; @Data @@ -15,7 +13,6 @@ @NoArgsConstructor public class ExternalGameResponse { private PlayerInfo playerInfo; - private List inventory; private List itemDef; private String worldView; private String backgroundStory; @@ -25,48 +22,20 @@ public class ExternalGameResponse { @NoArgsConstructor public static class PlayerInfo { private String name; - private int life; - private int level; - private int experiencePoint; - private int combatPoint; - private int healthPoint; + private Stat startStat; private String trait; - private int strength; - private int agility; - private int intelligence; - private int luck; - private int gold; - } - @Data - @AllArgsConstructor - @NoArgsConstructor - public static class InventoryItem { - private Long itemDefId; - private LocalDateTime acquiredAt; - private boolean equipped; - private String source; } @Data @AllArgsConstructor @NoArgsConstructor public static class ItemDef { - private Long itemDefId; - private String itemPicSrc; private String name; private String description; - private ItemType category; - private int baseStat; private List itemEffect; - private int strength; - private int agility; - private int intelligence; - private int luck; - private MainStat mainStat; - private int weight; + private Stat mainStat; private Grade grade; - private Long price; @Data @AllArgsConstructor @@ -75,7 +44,6 @@ public static class ItemEffect { private String itemEffectName; private String itemEffectDescription; private Grade grade; - private int itemEffectWeight; } } } diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index ab785e99..24a7c75b 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -1,8 +1,7 @@ package com.scriptopia.demo.dto.items; -import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.MainStat; +import com.scriptopia.demo.domain.Stat; import lombok.Data; import java.util.List; @@ -15,7 +14,7 @@ public class ItemDefRequest { private String picSrc; private ItemType itemType; - private MainStat mainStat; + private Stat stat; private Integer baseStat; private Integer strength; diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 281633a7..01067358 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -39,7 +39,7 @@ WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) AND (:grade IS NULL OR id.itemGradeDef.grade = :grade) AND (:minPrice IS NULL OR a.price >= :minPrice) AND (:maxPrice IS NULL OR a.price <= :maxPrice) - AND (:mainStat IS NULL OR id.mainStat = :mainStat) + AND (:stat IS NULL OR id.stat = :stat) AND ( :effectGrades IS NULL OR EXISTS ( @@ -54,7 +54,7 @@ Page findByFilters( @Param("grade") Grade grade, @Param("minPrice") Long minPrice, @Param("maxPrice") Long maxPrice, - @Param("mainStat") MainStat mainStat, + @Param("stat") Stat stat, @Param("effectGrades") List effectGrades, Pageable pageable ); diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 87e020a7..e96828b0 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -96,7 +96,7 @@ public TradeResponse getTrades(TradeFilterRequest request) { request.getGrade(), request.getMinPrice(), request.getMaxPrice(), - request.getMainStat(), + request.getStat(), request.getEffectGrades(), pageable ); @@ -331,7 +331,7 @@ public MySaleItemResponse getMySaleItems(Long userId, MySaleItemRequest requestD auction.getUserItem().getItemDef().getName(), auction.getUserItem().getItemDef().getItemGradeDef().getGrade().name(), auction.getUserItem().getItemDef().getItemType().name(), - auction.getUserItem().getItemDef().getMainStat().name(), + auction.getUserItem().getItemDef().getStat().name(), auction.getUserItem().getItemDef().getPicSrc() ) )).toList(); diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 5dbb0867..98be937a 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -39,7 +39,7 @@ public ItemDefResponse createItem(ItemDefRequest dto) { itemDef.setDescription(dto.getDescription()); itemDef.setPicSrc(dto.getPicSrc()); itemDef.setItemType(dto.getItemType()); - itemDef.setMainStat(dto.getMainStat()); + itemDef.setStat(dto.getStat()); itemDef.setBaseStat(dto.getBaseStat()); itemDef.setStrength(dto.getStrength()); itemDef.setAgility(dto.getAgility()); @@ -80,7 +80,7 @@ private ItemDefResponse toResponse(ItemDef itemDef) { response.setDescription(itemDef.getDescription()); response.setPicSrc(itemDef.getPicSrc()); response.setItemType(itemDef.getItemType().name()); - response.setMainStat(itemDef.getMainStat().name()); + response.setMainStat(itemDef.getStat().name()); response.setBaseStat(itemDef.getBaseStat()); response.setStrength(itemDef.getStrength()); response.setAgility(itemDef.getAgility()); From 5db74d5b1fa16980c382160194b7d3b2d971fda5 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:24:57 +0900 Subject: [PATCH 226/527] feat create Query method findPricebyGrade --- .../scriptopia/demo/repository/EffectGradeDefRepository.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index fa291f75..ead33dad 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -2,10 +2,15 @@ import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.Grade; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; public interface EffectGradeDefRepository extends JpaRepository { Optional findByGrade(Grade grade); + + @Query("SELECT egd.price FROM EffectGradeDef egd WHERE egd.grade = :grade") + Integer findPriceByGrade(@Param("grade") Grade grade); } From c1fd2fd995eb8dfb1badadd080eea516d5321b38 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:25:30 +0900 Subject: [PATCH 227/527] feat create Query method findPricebyGrade in ItemGradeDefRepository --- .../scriptopia/demo/repository/ItemGradeDefRepository.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java index 7060c9fb..b256e027 100644 --- a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java @@ -2,11 +2,17 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemGradeDef; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; public interface ItemGradeDefRepository extends JpaRepository { Optional findByGrade(Grade grade); + @Query("SELECT igm.price FROM ItemGradeDef igm WHERE igm.grade = :grade") + Integer findPriceByGrade(@Param("grade") Grade grade); + + } From 9ba3809f302f4cc15aeffb8dccf18408b3b76282 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:28:04 +0900 Subject: [PATCH 228/527] feat: implemnet InitGameData for move stat data adjustment responsibility to backend --- .../scriptopia/demo/config/InitGameData.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/InitGameData.java diff --git a/src/main/java/com/scriptopia/demo/config/InitGameData.java b/src/main/java/com/scriptopia/demo/config/InitGameData.java new file mode 100644 index 00000000..99beabbb --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/InitGameData.java @@ -0,0 +1,73 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.EffectGradeDef; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.Stat; +import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemGradeDefRepository; +import com.scriptopia.demo.utils.GameBalanceUtil; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +import java.security.SecureRandom; + +@Data +public class InitGameData { + + static SecureRandom secureRandom = new SecureRandom(); + private ItemGradeDefRepository itemGradeDefRepository; + private EffectGradeDefRepository effectGradeDefRepository; + + static final int PLAYER_BASE_STAT = 5; + + //PlayerInfo + private int life; + private int healthPoint; + private int playerStr; + private int playerAgi; + private int playerInt; + private int playerLuk; + private int gold; + + //ItemDef + private ItemType category; + private int baseStat; + private int itemEffectWeight; + private int itemStr; + private int itemAgi; + private int itemInt; + private int itemLuk; + private int itemPrice; + + public InitGameData(Stat playerStat, Grade grade) { + this.life = 5; + this.healthPoint = 80; + + int mainStat = secureRandom.nextInt(3); + int subStat = secureRandom.nextInt(3) - 2; + + this.playerStr = (playerStat.equals(Stat.STRENGTH)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + this.playerAgi = (playerStat.equals(Stat.AGILITY)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + this.playerInt = (playerStat.equals(Stat.INTELLIGENCE)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + this.playerLuk = (playerStat.equals(Stat.LUCK)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + this.gold = secureRandom.nextInt(50) + 50; + + this.category = ItemType.WEAPON; + + int[] stats = GameBalanceUtil.initItemStat(grade); + this.itemStr = stats[0]; + this.itemAgi = stats[1]; + this.itemInt = stats[2]; + this.itemLuk = stats[3]; + + int rate = secureRandom.nextInt(21) - 10; + int gradePrice = itemGradeDefRepository.findPriceByGrade(grade); + int effectPrice = (int) Math.floor(effectGradeDefRepository.findPriceByGrade(grade) * (1 + rate / 100.0)); + + this.itemPrice = gradePrice + effectPrice; + + } + + +} From b53363184bc624fc5bb04700b25097e47f465864 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:32:11 +0900 Subject: [PATCH 229/527] wip: refactoring create game session --- .../demo/service/GameSessionService.java | 22 ++++--------------- .../demo/utils/GameBalanceUtil.java | 21 ++++++++++++++++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index dc15854a..481da0c2 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.config.InitGameData; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.domain.mongo.*; import com.scriptopia.demo.dto.gamesession.*; @@ -18,7 +19,6 @@ import java.util.Collections; import java.util.List; import java.util.Random; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -71,7 +71,6 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { @Transactional public StartGameResponse startNewGame(Long userId, StartGameRequest request) { - // 1. 진행중인 게임 체크 if (gameSessionRepository.existsByUser_Id(userId)) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); @@ -115,21 +114,9 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } + InitGameData initGameData = new InitGameData(externalGame.getPlayerInfo().getStartStat(), Grade.COMMON); - - // 3. 밸런스 재세팅 - ExternalGameResponse.PlayerInfo player = externalGame.getPlayerInfo(); - player.setLife(5); - player.setLevel(1); - player.setExperiencePoint(0); - - // 4. 아이템 적용 및 전투력 계산 - GameBalanceUtil.applyEquippedWeaponStatsAndCombatPoint(externalGame); - GameBalanceUtil.applyEquippedArmorStatsAndHealthPoint(externalGame); - GameBalanceUtil.applyEquippedArtifactStats(externalGame); - - - // 5. MongoDB 저장 + // MongoDB 저장 GameSessionMongo mongoSession = new GameSessionMongo(); mongoSession.setUserId(userId); mongoSession.setSceneType(SceneType.CHOICE); // 시작은 choice 기본값 @@ -222,7 +209,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { userItemDef.getAgility(), userItemDef.getIntelligence(), userItemDef.getLuck(), - userItemDef.getMainStat(), + userItemDef.getStat(), 1, userItemDef.getItemGradeDef().getGrade(), userItemDef.getPrice() @@ -255,7 +242,6 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setItemDef(mongoItemDefs); - // 플레이어 정보 ExternalGameResponse.PlayerInfo p = externalGame.getPlayerInfo(); PlayerInfoMongo playerMongo = new PlayerInfoMongo( diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index e5528fbc..f1e66c4d 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -4,11 +4,13 @@ import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; +import java.security.SecureRandom; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class GameBalanceUtil { + static SecureRandom secureRandom = new SecureRandom(); // 착용 무기 적용 후 캐릭터 스탯 및 combat_point 계산 public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse game) { @@ -158,4 +160,23 @@ private static double getMainStatMultiplier(int statValue) { if (statValue <= 45) return 1.9; return 2.0; // 46 이상 } + + + public static int[] initItemStat(Grade grade) { + int[] stats = new int[4]; // str,agi,int,luk + int itemBaseStat = 0; + + switch (grade) { + case COMMON -> itemBaseStat = secureRandom.nextInt(4); + case UNCOMMON -> itemBaseStat = secureRandom.nextInt(4) + 1; + case RARE -> itemBaseStat = secureRandom.nextInt(4) + 2; + case EPIC -> itemBaseStat = secureRandom.nextInt(4) + 4; + case LEGENDARY -> itemBaseStat = secureRandom.nextInt(4) + 5; + } + + for(int i = 0; i < itemBaseStat; i++){ + stats[secureRandom.nextInt(4)] += 1; + } + return stats; + } } From 87e44cd309bc644b1fd669a34f3260e700fd98f6 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:30:16 +0900 Subject: [PATCH 230/527] feat: update Grade enum class > mapping enum with attackPower,armorPower --- .../com/scriptopia/demo/domain/Grade.java | 27 +++++++++++++++---- .../GameSessionMongoRepository.java | 0 .../demo/{config => utils}/InitGameData.java | 0 3 files changed, 22 insertions(+), 5 deletions(-) rename src/main/java/com/scriptopia/demo/repository/{ => mongo}/GameSessionMongoRepository.java (100%) rename src/main/java/com/scriptopia/demo/{config => utils}/InitGameData.java (100%) diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 172bd74a..62fa7fb8 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -1,9 +1,26 @@ package com.scriptopia.demo.domain; public enum Grade { - COMMON, - UNCOMMON, - RARE, - EPIC, - LEGENDARY + COMMON(30, 137), + UNCOMMON(35, 177), + RARE(40, 216), + EPIC(45, 260), + LEGENDARY(50, 312); + + private final int attackPower; // 무기 공격력 + private final int defensePower; // 방어구 방어력 + + Grade(int attackPower, int defensePower) { + this.attackPower = attackPower; + this.defensePower = defensePower; + } + + public int getAttackPower() { + return attackPower; + } + + public int getDefensePower() { + return defensePower; + } + } diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java b/src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java similarity index 100% rename from src/main/java/com/scriptopia/demo/repository/GameSessionMongoRepository.java rename to src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java diff --git a/src/main/java/com/scriptopia/demo/config/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java similarity index 100% rename from src/main/java/com/scriptopia/demo/config/InitGameData.java rename to src/main/java/com/scriptopia/demo/utils/InitGameData.java From 4b827b7b519ff1965823e8b16be52fd5c9b3d44b Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:33:39 +0900 Subject: [PATCH 231/527] feat: update Mongo entities add Builder annotation seperate collection ItemDefMongo --- .../demo/domain/mongo/BattleInfoMongo.java | 9 +++------ .../demo/domain/mongo/BattleTurnMongo.java | 9 +++------ .../demo/domain/mongo/ChoiceInfoMongo.java | 9 +++------ .../scriptopia/demo/domain/mongo/ChoiceMongo.java | 9 +++------ .../demo/domain/mongo/DoneInfoMongo.java | 9 +++------ .../demo/domain/mongo/GameSessionMongo.java | 13 ++++++------- .../demo/domain/mongo/HistoryInfoMongo.java | 9 +++------ .../demo/domain/mongo/InventoryItemMongo.java | 12 ++++++++---- .../demo/domain/mongo/ItemDefMongo.java | 15 ++++++++------- .../demo/domain/mongo/ItemEffectMongo.java | 10 +++------- .../demo/domain/mongo/PlayerInfoMongo.java | 10 ++++------ .../demo/domain/mongo/RewardInfoMongo.java | 9 +++------ .../demo/domain/mongo/ShopInfoMongo.java | 9 +++------ 13 files changed, 53 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java index 150fbad5..892426c4 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java @@ -1,14 +1,11 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.util.List; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class BattleInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java index 17e1a567..be6a20e1 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java @@ -1,12 +1,9 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class BattleTurnMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java index 64cd7d05..d4eee367 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceInfoMongo.java @@ -1,15 +1,12 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.ChoiceEventType; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.util.List; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class ChoiceInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index b616766f..7f279cd6 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -2,14 +2,11 @@ import com.scriptopia.demo.domain.ChoiceResultType; import com.scriptopia.demo.domain.Stat; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class ChoiceMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java index 8926ce0a..eae0980c 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/DoneInfoMongo.java @@ -1,12 +1,9 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class DoneInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index fba86901..f0c29117 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -1,19 +1,16 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.SceneType; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import java.time.LocalDateTime; import java.util.List; -@Getter -@Setter +@Data @Document(collection = "game_sessions") +@Builder @AllArgsConstructor @NoArgsConstructor public class GameSessionMongo { @@ -29,12 +26,14 @@ public class GameSessionMongo { private LocalDateTime updatedAt; private String background; + private String location; private Integer progress; private List stage; private PlayerInfoMongo playerInfo; + private NpcInfoMongo npcInfo; private List inventory; - private List itemDef; + private List createdItems; private ChoiceInfoMongo choiceInfo; private DoneInfoMongo doneInfo; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java index fdd92af0..217d7e2e 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java @@ -1,12 +1,9 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class HistoryInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java index 74180600..e092da51 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java @@ -1,15 +1,19 @@ package com.scriptopia.demo.domain.mongo; +import jakarta.persistence.Id; import lombok.*; import java.time.LocalDateTime; -@Getter -@Setter -@NoArgsConstructor +@Data +@Builder @AllArgsConstructor +@NoArgsConstructor public class InventoryItemMongo { - private Long itemDefId; + @Id + private String id; + + private String ItemDefId; private LocalDateTime acquiredAt; private Boolean equipped; private String source; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index 32bdc9c9..5273b5fe 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -3,19 +3,19 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import jakarta.persistence.Id; +import lombok.*; import java.util.List; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class ItemDefMongo { - private Long itemDefId; + @Id + private String id; + private String itemPicSrc; private String name; private String description; @@ -29,4 +29,5 @@ public class ItemDefMongo { private Stat mainStat; // strength, agility, intelligence, luck private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY private Long price; + } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java index 014206cf..d691c9bf 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java @@ -1,18 +1,14 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.Grade; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class ItemEffectMongo { private String itemEffectName; private String itemEffectDescription; private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY - private Integer itemEffectWeight; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java index c6bc2af6..6adfee4d 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java @@ -1,12 +1,10 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; +import org.springframework.data.mongodb.core.mapping.Document; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class PlayerInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java index 16b8970e..4c6cde76 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java @@ -1,14 +1,11 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.util.List; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class RewardInfoMongo { diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java index b6ffdd4f..8518ba98 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java @@ -1,14 +1,11 @@ package com.scriptopia.demo.domain.mongo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.util.List; -@Getter -@Setter +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class ShopInfoMongo { From eca107ec6708eea08a189658588528864852203d Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:34:36 +0900 Subject: [PATCH 232/527] feat: add location variable --- .../demo/dto/gamesession/ExternalGameResponse.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java index 9df8dda7..f36ee735 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ExternalGameResponse.java @@ -13,9 +13,10 @@ @NoArgsConstructor public class ExternalGameResponse { private PlayerInfo playerInfo; - private List itemDef; + private ItemDef itemDef; private String worldView; private String backgroundStory; + private String location; @Data @AllArgsConstructor @@ -33,7 +34,7 @@ public static class PlayerInfo { public static class ItemDef { private String name; private String description; - private List itemEffect; + private ItemEffect itemEffect; private Stat mainStat; private Grade grade; From ffedb8c337a8aef22ccbaadbd4c70209f7d48dfd Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:35:46 +0900 Subject: [PATCH 233/527] feat: seperate other db package move mongoRepository to mongo db package --- .../demo/repository/mongo/GameSessionMongoRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java b/src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java index 3fe450a8..f3c292de 100644 --- a/src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/mongo/GameSessionMongoRepository.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.repository; +package com.scriptopia.demo.repository.mongo; import com.scriptopia.demo.domain.mongo.GameSessionMongo; import org.springframework.data.mongodb.repository.MongoRepository; From fe4ba63ef7d8d3ac8b145542c3029b09e6cf0153 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:37:20 +0900 Subject: [PATCH 234/527] feat: create ItemDefMongoRepository for seperate collection in game session collection --- .../demo/repository/mongo/ItemDefMongoRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java diff --git a/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java b/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java new file mode 100644 index 00000000..178b974f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.repository.mongo; + +import com.scriptopia.demo.domain.ItemDef; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ItemDefMongoRepository extends MongoRepository { +} From 446705c028b7aa26e027bb5da199c0d24960c7dc Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:38:21 +0900 Subject: [PATCH 235/527] feat: create NpcInfoMongo --- .../demo/domain/mongo/NpcInfoMongo.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/mongo/NpcInfoMongo.java diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/NpcInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/NpcInfoMongo.java new file mode 100644 index 00000000..6edfed68 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/mongo/NpcInfoMongo.java @@ -0,0 +1,24 @@ +package com.scriptopia.demo.domain.mongo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Document(collection = "item_def") +public class NpcInfoMongo { + private String name; + private Integer rank; + private String trait; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private String NpcWeaponName; + private String NpcWeaponDescription; +} From ade16628fd09f464c24c04cd2bd88864a3fe3688 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:38:59 +0900 Subject: [PATCH 236/527] feat: update value types --- .../scriptopia/demo/utils/InitGameData.java | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 99beabbb..ece6778e 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -1,14 +1,13 @@ -package com.scriptopia.demo.config; +package com.scriptopia.demo.utils; -import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; -import com.scriptopia.demo.utils.GameBalanceUtil; import lombok.Data; -import lombok.RequiredArgsConstructor; import java.security.SecureRandom; @@ -22,23 +21,22 @@ public class InitGameData { static final int PLAYER_BASE_STAT = 5; //PlayerInfo - private int life; - private int healthPoint; - private int playerStr; - private int playerAgi; - private int playerInt; - private int playerLuk; - private int gold; + private Integer life; + private Integer healthPoint; + private Integer playerStr; + private Integer playerAgi; + private Integer playerInt; + private Integer playerLuk; + private Long gold; //ItemDef private ItemType category; - private int baseStat; - private int itemEffectWeight; - private int itemStr; - private int itemAgi; - private int itemInt; - private int itemLuk; - private int itemPrice; + private Integer baseStat; + private Integer itemStr; + private Integer itemAgi; + private Integer itemInt; + private Integer itemLuk; + private Long itemPrice; public InitGameData(Stat playerStat, Grade grade) { this.life = 5; @@ -51,19 +49,23 @@ public InitGameData(Stat playerStat, Grade grade) { this.playerAgi = (playerStat.equals(Stat.AGILITY)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; this.playerInt = (playerStat.equals(Stat.INTELLIGENCE)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; this.playerLuk = (playerStat.equals(Stat.LUCK)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; - this.gold = secureRandom.nextInt(50) + 50; + this.gold = secureRandom.nextLong(50) + 50; this.category = ItemType.WEAPON; + int attackRate = secureRandom.nextInt(21) - 10; + this.baseStat = (int) Math.floor(Grade.COMMON.getAttackPower() * (1 + attackRate / 100.0)); + + int[] stats = GameBalanceUtil.initItemStat(grade); this.itemStr = stats[0]; this.itemAgi = stats[1]; this.itemInt = stats[2]; this.itemLuk = stats[3]; - int rate = secureRandom.nextInt(21) - 10; - int gradePrice = itemGradeDefRepository.findPriceByGrade(grade); - int effectPrice = (int) Math.floor(effectGradeDefRepository.findPriceByGrade(grade) * (1 + rate / 100.0)); + int priceRate = secureRandom.nextInt(21) - 10; + Long gradePrice = (long) itemGradeDefRepository.findPriceByGrade(grade); + Long effectPrice = (long) Math.floor(effectGradeDefRepository.findPriceByGrade(grade) * (1 + priceRate / 100.0)); this.itemPrice = gradePrice + effectPrice; From b5f244f5be0ec4f08262130ca7492c49fcd27573 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:43:56 +0900 Subject: [PATCH 237/527] test commit --- src/main/java/com/scriptopia/demo/utils/InitGameData.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index ece6778e..83bc3e16 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -71,5 +71,4 @@ public InitGameData(Stat playerStat, Grade grade) { } - } From 315407419066ad0b65f46a9635ac67f4db6a65b0 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 20:14:02 +0900 Subject: [PATCH 238/527] feat: delegate MongoDB data management to backend --- build.gradle | 1 + .../controller/GameSessionController.java | 5 +- .../com/scriptopia/demo/domain/Grade.java | 11 +- .../java/com/scriptopia/demo/domain/Stat.java | 4 - .../demo/domain/mongo/GameSessionMongo.java | 2 +- ...toryItemMongo.java => InventoryMongo.java} | 2 +- .../demo/domain/mongo/PlayerInfoMongo.java | 2 +- .../exception/GlobalExceptionHandler.java | 14 +- .../repository/EffectGradeDefRepository.java | 3 +- .../repository/ItemGradeDefRepository.java | 4 +- .../mongo/ItemDefMongoRepository.java | 4 +- .../demo/service/GameSessionService.java | 295 ++++++++---------- .../demo/utils/GameBalanceUtil.java | 150 --------- .../scriptopia/demo/utils/InitGameData.java | 48 ++- 14 files changed, 191 insertions(+), 354 deletions(-) rename src/main/java/com/scriptopia/demo/domain/mongo/{InventoryItemMongo.java => InventoryMongo.java} (90%) diff --git a/build.gradle b/build.gradle index 69ca97f8..36d6a55b 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' // DB 관련 runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 98c2207c..0126ada6 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; import com.scriptopia.demo.service.GameSessionService; @@ -36,8 +38,7 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV @PostMapping public ResponseEntity startNewGame( @RequestBody StartGameRequest request, - Authentication authentication) { - + Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 62fa7fb8..172af5b7 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -1,5 +1,8 @@ package com.scriptopia.demo.domain; +import lombok.Getter; + +@Getter public enum Grade { COMMON(30, 137), UNCOMMON(35, 177), @@ -15,12 +18,4 @@ public enum Grade { this.defensePower = defensePower; } - public int getAttackPower() { - return attackPower; - } - - public int getDefensePower() { - return defensePower; - } - } diff --git a/src/main/java/com/scriptopia/demo/domain/Stat.java b/src/main/java/com/scriptopia/demo/domain/Stat.java index 05e08c3e..a9989129 100644 --- a/src/main/java/com/scriptopia/demo/domain/Stat.java +++ b/src/main/java/com/scriptopia/demo/domain/Stat.java @@ -3,12 +3,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; public enum Stat { - @JsonProperty("intelligence") INTELLIGENCE, - @JsonProperty("strength") STRENGTH, - @JsonProperty("agility") AGILITY, - @JsonProperty("luck") LUCK } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index f0c29117..00a68bf4 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -32,7 +32,7 @@ public class GameSessionMongo { private PlayerInfoMongo playerInfo; private NpcInfoMongo npcInfo; - private List inventory; + private List inventory; private List createdItems; private ChoiceInfoMongo choiceInfo; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java similarity index 90% rename from src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java rename to src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java index e092da51..794c33d4 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryItemMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java @@ -9,7 +9,7 @@ @Builder @AllArgsConstructor @NoArgsConstructor -public class InventoryItemMongo { +public class InventoryMongo { @Id private String id; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java index 6adfee4d..6adbb068 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/PlayerInfoMongo.java @@ -18,5 +18,5 @@ public class PlayerInfoMongo { private Integer agility; // 난수 private Integer intelligence; // 난수 private Integer luck; // 난수 - private Integer gold; // 난수 + private Long gold; // 난수 } diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 127ffbf3..306f84b4 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -65,13 +65,13 @@ public ResponseEntity handleCustomException(final CustomException } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse(ErrorCode.E_500)); - } +// @ExceptionHandler(Exception.class) +// public ResponseEntity handleGeneralException(Exception ex) { +// +// return ResponseEntity +// .status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(new ErrorResponse(ErrorCode.E_500)); +// } @ExceptionHandler(ExpiredJwtException.class) public ResponseEntity handleExpired(ExpiredJwtException e) { diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index ead33dad..d8fca7c2 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -2,9 +2,10 @@ import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.Grade; -import io.lettuce.core.dynamic.annotation.Param; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; diff --git a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java index b256e027..1547636e 100644 --- a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java @@ -2,12 +2,14 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemGradeDef; -import io.lettuce.core.dynamic.annotation.Param; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; + public interface ItemGradeDefRepository extends JpaRepository { Optional findByGrade(Grade grade); diff --git a/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java b/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java index 178b974f..4325b865 100644 --- a/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/mongo/ItemDefMongoRepository.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.repository.mongo; -import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.domain.mongo.ItemDefMongo; import org.springframework.data.mongodb.repository.MongoRepository; -public interface ItemDefMongoRepository extends MongoRepository { +public interface ItemDefMongoRepository extends MongoRepository { } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 481da0c2..60dc6982 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,18 +1,24 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.config.InitGameData; +import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; +import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; +import com.scriptopia.demo.utils.InitGameData; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.domain.mongo.*; import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; -import com.scriptopia.demo.utils.GameBalanceUtil; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.PlayerInfo; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse.ItemDef; + + import lombok.RequiredArgsConstructor; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; import java.util.ArrayList; @@ -23,7 +29,10 @@ @Service @RequiredArgsConstructor public class GameSessionService { + private final ItemDefMongoRepository itemDefMongoRepository; private final GameSessionRepository gameSessionRepository; + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; private final UserRepository userRepository; private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; @@ -97,26 +106,36 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { request.getCharacterDescription() ); - // 2. FastAPI 호출(테스트용 추후 변경 가능) - String url = "http://localhost:8000/games/init"; - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity requestEntity = new HttpEntity<>(createGameRequest, headers); - ResponseEntity responseEntity = - restTemplate.exchange(url, HttpMethod.POST, requestEntity, ExternalGameResponse.class); + // WebClient 인스턴스 생성 + WebClient client = WebClient.builder() + .baseUrl("http://localhost:8000") + .build(); - ExternalGameResponse externalGame = responseEntity.getBody(); + // FastAPI 호출(테스트용 추후 변경 가능) + ExternalGameResponse externalGame = client.post() + .uri("/games/init") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(createGameRequest) // + .retrieve() + .bodyToMono(ExternalGameResponse.class) + .block(); // +// 응답 검증 if (externalGame == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } - InitGameData initGameData = new InitGameData(externalGame.getPlayerInfo().getStartStat(), Grade.COMMON); + InitGameData initGameData = new InitGameData( + externalGame.getPlayerInfo().getStartStat(), + Grade.COMMON, + itemGradeDefRepository, + effectGradeDefRepository - // MongoDB 저장 + ); + + // GameSession Data GameSessionMongo mongoSession = new GameSessionMongo(); mongoSession.setUserId(userId); mongoSession.setSceneType(SceneType.CHOICE); // 시작은 choice 기본값 @@ -124,168 +143,108 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setUpdatedAt(LocalDateTime.now()); mongoSession.setBackground(request.getBackground()); mongoSession.setProgress(0); + mongoSession.setStage(initGameData.getStages()); + + // PlayerInfo Data + PlayerInfo playerInfo = externalGame.getPlayerInfo(); + + PlayerInfoMongo playerInfoMongo = PlayerInfoMongo.builder() + .name(playerInfo.getName()) + .life(initGameData.getLife()) + .level(initGameData.getLevel()) + .healthPoint(initGameData.getHealthPoint()) + .experiencePoint(initGameData.getExperiencePoint()) + .trait(playerInfo.getTrait()) + .strength(initGameData.getPlayerStr()) + .agility(initGameData.getPlayerAgi()) + .intelligence(initGameData.getPlayerInt()) + .luck(initGameData.getPlayerLuk()) + .gold(initGameData.getGold()) + .build(); + + mongoSession.setPlayerInfo(playerInfoMongo); + + // NpcInfo Data + mongoSession.setNpcInfo(new NpcInfoMongo()); + + //ItemEffect Data + ItemDef itemDef = externalGame.getItemDef(); + ItemDef.ItemEffect itemEffect = itemDef.getItemEffect(); + + List itemEffectsMongo = new ArrayList<>(); + itemEffectsMongo.add( + ItemEffectMongo.builder() + .itemEffectName(itemEffect.getItemEffectName()) + .itemEffectDescription(itemEffect.getItemEffectDescription()) + .grade(Grade.COMMON) + .build() + ); - - // 아이템 정보 - List mongoInventory = new ArrayList<>(); - List mongoItemDefs = new ArrayList<>(); - - // FAST API 아이템 변환 - if (externalGame.getItemDef() != null) { - for (var item : externalGame.getItemDef()) { - var effects = item.getItemEffect() != null - ? item.getItemEffect().stream() - .map(e -> new ItemEffectMongo( - e.getItemEffectName(), - e.getItemEffectDescription(), - e.getGrade(), - e.getItemEffectWeight() - )) - .toList() - : Collections.emptyList(); - - ItemDefMongo itemDefMongo = new ItemDefMongo( - item.getItemDefId(), - item.getItemPicSrc(), - item.getName(), - item.getDescription(), - item.getCategory(), - item.getBaseStat(), - (List) effects, - item.getStrength(), - item.getAgility(), - item.getIntelligence(), - item.getLuck(), - item.getMainStat(), - item.getWeight(), - item.getGrade(), - item.getPrice() - ); - - mongoItemDefs.add(itemDefMongo); - } - } - - if (externalGame.getInventory() != null) { - for (var inv : externalGame.getInventory()) { - mongoInventory.add(new InventoryItemMongo( - inv.getItemDefId(), - inv.getAcquiredAt(), - inv.isEquipped(), - inv.getSource() - )); - } - } - - long maxItemDefId = mongoItemDefs.stream() - .mapToLong(ItemDefMongo::getItemDefId) - .max() - .orElse(0L); - - // 2. 사용자 아이템 추가 (두 번째 위치) - if (userItem != null) { - ItemDef userItemDef = userItem.getItemDef(); - - long newItemDefId = maxItemDefId + 1; - - ItemDefMongo userItemDefMongo = new ItemDefMongo( - newItemDefId, - userItemDef.getPicSrc(), - userItemDef.getName(), - userItemDef.getDescription(), - userItemDef.getItemType(), - userItemDef.getBaseStat(), - userItemDef.getItemEffects() != null - ? userItemDef.getItemEffects().stream() - .map(e -> new ItemEffectMongo( - e.getEffectName(), - e.getEffectDescription(), - e.getEffectGradeDef().getGrade(), - 1 - )) - .toList() - : Collections.emptyList(), - userItemDef.getStrength(), - userItemDef.getAgility(), - userItemDef.getIntelligence(), - userItemDef.getLuck(), - userItemDef.getStat(), - 1, - userItemDef.getItemGradeDef().getGrade(), - userItemDef.getPrice() - ); - - // 아이템 정의 리스트 두 번째 위치에 삽입 - if (mongoItemDefs.size() >= 1) { - mongoItemDefs.add(1, userItemDefMongo); - } else { - mongoItemDefs.add(userItemDefMongo); - } - - // Inventory 리스트에도 두 번째 위치에 삽입 - InventoryItemMongo userInventoryMongo = new InventoryItemMongo( - userItemDefMongo.getItemDefId(), - LocalDateTime.now(), - false, - "USER_ITEM" - ); - - if (mongoInventory.size() >= 1) { - mongoInventory.add(1, userInventoryMongo); - } else { - mongoInventory.add(userInventoryMongo); - } - } - - // 3. MongoSession 저장 - mongoSession.setInventory(mongoInventory); - mongoSession.setItemDef(mongoItemDefs); - - - // 플레이어 정보 - ExternalGameResponse.PlayerInfo p = externalGame.getPlayerInfo(); - PlayerInfoMongo playerMongo = new PlayerInfoMongo( - p.getName(), - p.getLife(), - p.getLevel(), - p.getExperiencePoint(), - p.getCombatPoint(), - p.getHealthPoint(), - p.getTrait(), - p.getStrength(), - p.getAgility(), - p.getIntelligence(), - p.getLuck(), - p.getGold() + //ItemDef Data + ItemDefMongo itemDefMongo = ItemDefMongo.builder() + .itemPicSrc("common item img") + .name(itemDef.getName()) + .description(itemDef.getDescription()) + .category(ItemType.WEAPON) + .baseStat(initGameData.getBaseStat()) + .itemEffect(itemEffectsMongo) + .strength(initGameData.getItemStr()) + .agility(initGameData.getItemAgi()) + .intelligence(initGameData.getItemInt()) + .luck(initGameData.getItemLuk()) + .mainStat(itemDef.getMainStat()) + .grade(Grade.COMMON) + .price(initGameData.getItemPrice()) + .build(); + + ItemDefMongo savedItemDefMongo = itemDefMongoRepository.save(itemDefMongo); + + // CreatedItem Data + List createdItems = new ArrayList<>(); + createdItems.add(savedItemDefMongo.getId()); + + mongoSession.setCreatedItems(createdItems); + + // Inventory Data + List inventoryMongoList = new ArrayList<>(); + inventoryMongoList.add(InventoryMongo.builder() + .ItemDefId(savedItemDefMongo.getId()) + .acquiredAt(LocalDateTime.now()) + .equipped(true) + .source("StartWeapon") + .build() ); - mongoSession.setPlayerInfo(playerMongo); - - // 초기 히스토리 저장 - HistoryInfoMongo history = new HistoryInfoMongo(); - history.setWorldView(externalGame.getWorldView()); - history.setBackgroundStory(externalGame.getBackgroundStory()); - mongoSession.setHistoryInfo(history); - - - int stageCount = 10; // 예: 10스테이지 - List stageList = new ArrayList<>(); - Random random = new Random(); - for (int i = 0; i < stageCount; i++) { - // 0: 작은 이벤트, 1: 큰 이벤트 - stageList.add(random.nextInt(2)); - } - mongoSession.setStage(stageList); - // 게임 진행 시 필요한 것들은 만들어만 두기 + mongoSession.setInventory(inventoryMongoList); + + // ChoiceInfo Data mongoSession.setChoiceInfo(new ChoiceInfoMongo()); + + //DoneInfoMongo Data mongoSession.setDoneInfo(new DoneInfoMongo()); + + //ShopInfoMongo Data mongoSession.setShopInfo(new ShopInfoMongo()); + + //BattleInfoMongo Data mongoSession.setBattleInfo(new BattleInfoMongo()); + + //RewardInfoMongo Data mongoSession.setRewardInfo(new RewardInfoMongo()); + //HistoryInfoMongo Data + mongoSession.setHistoryInfo( + HistoryInfoMongo.builder() + .worldView(externalGame.getWorldView()) + .backgroundStory(externalGame.getBackgroundStory()) + .worldPrompt(request.getBackground()) + .build() + ); + + // 최종 GameSession 저장 GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); - // 5. MySQL GameSession에 MongoDB PK 저장 + // MySQL GameSession MongoDB PK 저장 User user = userRepository.findById(userId).orElseThrow( () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) ); @@ -300,13 +259,11 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { userItemRepository.save(userItem); } - StartGameResponse startGameResponse = new StartGameResponse( + // MongoDB PK 반환 + return new StartGameResponse( "게임이 생성되었습니다.", mysqlSession.getMongoId() ); - - // 6. MongoDB PK 반환 - return startGameResponse; } diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index f1e66c4d..e5bd53ba 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -12,156 +12,6 @@ public class GameBalanceUtil { static SecureRandom secureRandom = new SecureRandom(); - // 착용 무기 적용 후 캐릭터 스탯 및 combat_point 계산 - public static void applyEquippedWeaponStatsAndCombatPoint(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); - List itemDefs = game.getItemDef(); - List inventory = game.getInventory(); - - // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); - - // 착용 중인 무기 하나 찾기 - ExternalGameResponse.ItemDef equippedWeapon = inventory.stream() - .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItemDefId())) - .filter(item -> item != null && item.getCategory() == ItemType.WEAPON) - .findFirst() - .orElse(null); - - if (equippedWeapon != null) { - // 1. 무기 스탯 합산 - player.setStrength(player.getStrength() + equippedWeapon.getStrength()); - player.setAgility(player.getAgility() + equippedWeapon.getAgility()); - player.setIntelligence(player.getIntelligence() + equippedWeapon.getIntelligence()); - player.setLuck(player.getLuck() + equippedWeapon.getLuck()); - - // 2. combat_point 계산 - double effectMultiplier = equippedWeapon.getItemEffect().stream() - .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) - .sum(); - - int mainStatValue = switch (equippedWeapon.getMainStat()) { - case STRENGTH -> player.getStrength(); - case AGILITY -> player.getAgility(); - case INTELLIGENCE -> player.getIntelligence(); - case LUCK -> player.getLuck(); - default -> 0; - }; - - double mainStatMultiplier = getMainStatMultiplier(mainStatValue); - - int combatPoint = (int) (equippedWeapon.getBaseStat() * (1 + effectMultiplier) * mainStatMultiplier); - - player.setCombatPoint(combatPoint); - } else { - // 무기 미착용 시: 스탯 합 * 가장 높은 스탯 배율 - int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); - int maxStat = Math.max(Math.max(player.getStrength(), player.getAgility()), - Math.max(player.getIntelligence(), player.getLuck())); - double mainStatMultiplier = getMainStatMultiplier(maxStat); - int combatPoint = (int) (totalStat * mainStatMultiplier); - player.setCombatPoint(combatPoint); - } - } - - // 착용 방어구 적용 후 캐릭터 스탯 및 health_point 계산 - public static void applyEquippedArmorStatsAndHealthPoint(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); - List itemDefs = game.getItemDef(); - List inventory = game.getInventory(); - - // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); - - // 착용 중인 방어구 하나 찾기 - ExternalGameResponse.ItemDef equippedArmor = inventory.stream() - .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItemDefId())) - .filter(item -> item != null && item.getCategory() == ItemType.ARMOR) - .findFirst() - .orElse(null); - - if (equippedArmor != null) { - // 1. 방어구 스탯 합산 - player.setStrength(player.getStrength() + equippedArmor.getStrength()); - player.setAgility(player.getAgility() + equippedArmor.getAgility()); - player.setIntelligence(player.getIntelligence() + equippedArmor.getIntelligence()); - player.setLuck(player.getLuck() + equippedArmor.getLuck()); - - // 2. health_point 계산 - double effectMultiplier = equippedArmor.getItemEffect().stream() - .mapToDouble(e -> getEffectGradeMultiplier(e.getGrade())) - .sum(); - - int baseHealth = equippedArmor.getBaseStat(); - int healthPoint = (int) (baseHealth * (1 + effectMultiplier)); - player.setHealthPoint(healthPoint); - } else { - // 방어구 미착용 시: 전체 스탯 합 * 3 - int totalStat = player.getStrength() + player.getAgility() + player.getIntelligence() + player.getLuck(); - int healthPoint = totalStat * 3; - player.setHealthPoint(healthPoint); - } - } - - - // 착용 아티팩트 적용 후 캐릭터 스탯 계산 (전투력/체력 보정은 추후) - public static void applyEquippedArtifactStats(ExternalGameResponse game) { - ExternalGameResponse.PlayerInfo player = game.getPlayerInfo(); - List itemDefs = game.getItemDef(); - List inventory = game.getInventory(); - - // item_def_id 기준 Map 생성 - Map itemDefMap = itemDefs.stream() - .collect(Collectors.toMap(ExternalGameResponse.ItemDef::getItemDefId, item -> item)); - - // 착용 중인 아티팩트 하나 찾기 - ExternalGameResponse.ItemDef equippedArtifact = inventory.stream() - .filter(ExternalGameResponse.InventoryItem::isEquipped) - .map(inv -> itemDefMap.get(inv.getItemDefId())) - .filter(item -> item != null && item.getCategory() == ItemType.ARTIFACT) - .findFirst() - .orElse(null); - - if (equippedArtifact != null) { - // 아티팩트 스탯 합산 - player.setStrength(player.getStrength() + equippedArtifact.getStrength()); - player.setAgility(player.getAgility() + equippedArtifact.getAgility()); - player.setIntelligence(player.getIntelligence() + equippedArtifact.getIntelligence()); - player.setLuck(player.getLuck() + equippedArtifact.getLuck()); - } - } - - - // 아이템 효과 등급 배율 - private static double getEffectGradeMultiplier(Grade grade) { - return switch (grade) { - case COMMON -> 0.1; - case UNCOMMON -> 0.15; - case RARE -> 0.2; - case EPIC -> 0.25; - case LEGENDARY -> 0.3; - }; - } - - // 메인 스탯 값 구간 배율 - private static double getMainStatMultiplier(int statValue) { - if (statValue <= 5) return 1.1; - if (statValue <= 10) return 1.2; - if (statValue <= 15) return 1.3; - if (statValue <= 20) return 1.4; - if (statValue <= 25) return 1.5; - if (statValue <= 30) return 1.6; - if (statValue <= 35) return 1.7; - if (statValue <= 40) return 1.8; - if (statValue <= 45) return 1.9; - return 2.0; // 46 이상 - } - - public static int[] initItemStat(Grade grade) { int[] stats = new int[4]; // str,agi,int,luk int itemBaseStat = 0; diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 83bc3e16..95d73bef 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -1,28 +1,33 @@ package com.scriptopia.demo.utils; import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemDef; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; import com.scriptopia.demo.repository.EffectGradeDefRepository; -import com.scriptopia.demo.repository.ItemDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; -import lombok.Data; +import lombok.*; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; @Data public class InitGameData { - static SecureRandom secureRandom = new SecureRandom(); - private ItemGradeDefRepository itemGradeDefRepository; - private EffectGradeDefRepository effectGradeDefRepository; + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; + static SecureRandom secureRandom = new SecureRandom(); static final int PLAYER_BASE_STAT = 5; + //Game Session + private List stages; //PlayerInfo private Integer life; + private Integer level; private Integer healthPoint; + private Integer experiencePoint; private Integer playerStr; private Integer playerAgi; private Integer playerInt; @@ -38,8 +43,16 @@ public class InitGameData { private Integer itemLuk; private Long itemPrice; - public InitGameData(Stat playerStat, Grade grade) { + public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRepo, + EffectGradeDefRepository effectRepo) { + this.itemGradeDefRepository = itemRepo; + this.effectGradeDefRepository = effectRepo; + + this.stages = initStage(); + this.life = 5; + this.level = 1; + this.experiencePoint = 0; this.healthPoint = 80; int mainStat = secureRandom.nextInt(3); @@ -71,4 +84,25 @@ public InitGameData(Stat playerStat, Grade grade) { } + private List initStage(){ + List arr = Arrays.asList(1, 2, 3, 4, 5, 6); + List result = new ArrayList<>(); + + int leadingZeros = 2 + secureRandom.nextInt(3); // 2~4 + for (int j = 0; j < leadingZeros; j++) { + result.add(0); + } + + for (int i = 0; i < arr.size(); i++) { + result.add(arr.get(i)); + if (i < arr.size() - 1) { + int zeros = 2 + secureRandom.nextInt(3); + for (int j = 0; j < zeros; j++) { + result.add(0); + } + } + } + return result; + } + } From 62e1f0cd0e7439d938bdfb1d76630eb1913b48bf Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Tue, 2 Sep 2025 20:14:58 +0900 Subject: [PATCH 239/527] refactor: remove commented-out --- .../demo/exception/GlobalExceptionHandler.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 306f84b4..127ffbf3 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -65,13 +65,13 @@ public ResponseEntity handleCustomException(final CustomException } -// @ExceptionHandler(Exception.class) -// public ResponseEntity handleGeneralException(Exception ex) { -// -// return ResponseEntity -// .status(HttpStatus.INTERNAL_SERVER_ERROR) -// .body(new ErrorResponse(ErrorCode.E_500)); -// } + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(ErrorCode.E_500)); + } @ExceptionHandler(ExpiredJwtException.class) public ResponseEntity handleExpired(ExpiredJwtException e) { From 5533d3f876adb3f06e89b77efd5fd0e061754bf7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 5 Sep 2025 21:51:05 +0900 Subject: [PATCH 240/527] =?UTF-8?q?=C3=A3fix:=20delete=20ItemDefRequest=20?= =?UTF-8?q?dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scriptopia/demo/controller/ItemController.java | 5 +++-- .../scriptopia/demo/dto/items/ItemDefRequest.java | 14 +------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index 9c52f371..9e365880 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -14,9 +14,10 @@ public class ItemController { private final ItemDefService itemDefService; + @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest dto) { - ItemDefResponse savedItem = itemDefService.createItem(dto); + public ResponseEntity createItem() { + ItemDefResponse savedItem = itemDefService.createItem(); return ResponseEntity.ok(savedItem); } diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index 24a7c75b..be862895 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -9,21 +9,9 @@ @Data public class ItemDefRequest { + private String worldView; private String name; private String description; - private String picSrc; - - private ItemType itemType; - private Stat stat; - - private Integer baseStat; - private Integer strength; - private Integer agility; - private Integer intelligence; - private Integer luck; - - private Long itemGradeDefId; - private Long price; // 🔹 아이템 효과 리스트 private List effects; From 07f29abef9bdffb1ab0f18424a337f099a642b5d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 5 Sep 2025 22:00:54 +0900 Subject: [PATCH 241/527] feat: create getRandomStat becuase high cohesion --- .../java/com/scriptopia/demo/domain/Stat.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/Stat.java b/src/main/java/com/scriptopia/demo/domain/Stat.java index a9989129..6ff5a4b1 100644 --- a/src/main/java/com/scriptopia/demo/domain/Stat.java +++ b/src/main/java/com/scriptopia/demo/domain/Stat.java @@ -2,9 +2,24 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import java.security.SecureRandom; + public enum Stat { INTELLIGENCE, STRENGTH, AGILITY, - LUCK + LUCK; + + + + private static final SecureRandom random = new SecureRandom(); + + /** + * 무작위로 하나의 스탯을 반환 + */ + public static Stat getRandomStat() { + Stat[] values = Stat.values(); + return values[random.nextInt(values.length)]; + } + } From 9efd55125faa8dbd0c4000eb04ca130e95fd0689 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 5 Sep 2025 22:06:39 +0900 Subject: [PATCH 242/527] feat: create getRandomGrade becuase high cohesion --- src/main/java/com/scriptopia/demo/domain/Grade.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 172af5b7..0c082e9f 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -2,6 +2,8 @@ import lombok.Getter; +import java.security.SecureRandom; + @Getter public enum Grade { COMMON(30, 137), @@ -18,4 +20,15 @@ public enum Grade { this.defensePower = defensePower; } + private static final SecureRandom random = new SecureRandom(); + + /** + * Grade 중 하나를 랜덤으로 반환 + */ + public static Grade getRandomGrade() { + Grade[] values = Grade.values(); + return values[random.nextInt(values.length)]; + } + + } From 2d9986fa33a2aabf4399e907249e2643859e28d3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 5 Sep 2025 22:46:12 +0900 Subject: [PATCH 243/527] feat: create getRandomGrade becuase high cohesion --- .../com/scriptopia/demo/domain/Grade.java | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 0c082e9f..1379afc9 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -6,18 +6,20 @@ @Getter public enum Grade { - COMMON(30, 137), - UNCOMMON(35, 177), - RARE(40, 216), - EPIC(45, 260), - LEGENDARY(50, 312); + COMMON(30, 137, 50), + UNCOMMON(35, 177, 30), + RARE(40, 216, 15), + EPIC(45, 260, 4), + LEGENDARY(50, 312, 1); - private final int attackPower; // 무기 공격력 - private final int defensePower; // 방어구 방어력 + private final int attackPower; + private final int defensePower; + private final int dropRate; // 새로 추가한 필드 - Grade(int attackPower, int defensePower) { + Grade(int attackPower, int defensePower, int dropRate) { this.attackPower = attackPower; this.defensePower = defensePower; + this.dropRate = dropRate; } private static final SecureRandom random = new SecureRandom(); @@ -25,9 +27,18 @@ public enum Grade { /** * Grade 중 하나를 랜덤으로 반환 */ - public static Grade getRandomGrade() { - Grade[] values = Grade.values(); - return values[random.nextInt(values.length)]; + public static Grade getRandomGradeByProbability() { + int rand = random.nextInt(100) + 1; // 1~100 + int cumulative = 0; + + for (Grade grade : Grade.values()) { + cumulative += grade.getDropRate(); + if (rand <= cumulative) { + return grade; + } + } + + return LEGENDARY; } From 8f29b1dbee1e655e6ef11e8c46b630468b818505 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 5 Sep 2025 23:08:12 +0900 Subject: [PATCH 244/527] feat: create Chapter domain --- .../com/scriptopia/demo/domain/Chapter.java | 7 +++ .../com/scriptopia/demo/domain/NpcGrade.java | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/Chapter.java create mode 100644 src/main/java/com/scriptopia/demo/domain/NpcGrade.java diff --git a/src/main/java/com/scriptopia/demo/domain/Chapter.java b/src/main/java/com/scriptopia/demo/domain/Chapter.java new file mode 100644 index 00000000..6a5693c7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/Chapter.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.domain; + +public enum Chapter { + CHAPTER1, + CHAPTER2, + CHAPTER3 +} diff --git a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java new file mode 100644 index 00000000..91884614 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java @@ -0,0 +1,52 @@ +package com.scriptopia.demo.domain; + +import lombok.Getter; + +import java.security.SecureRandom; + +@Getter +public enum NpnGrade { + GRADE_1(1, 67, 70, 74, 2, 16, 18, 20), + GRADE_2(2, 100, 105, 110, 3, 23, 26, 29), + GRADE_3(3, 133, 140, 147, 4, 32, 35, 39), + GRADE_4(4, 144, 152, 160, 0, 34, 38, 42), + GRADE_5(5, 179, 188, 197, 0, 42, 47, 52), + GRADE_6(6, 194, 204, 214, 0, 46, 51, 56), + GRADE_7(7, 236, 248, 260, 0, 56, 62, 68), + GRADE_8(8, 255, 268, 281, 0, 60, 67, 74), + GRADE_9(9, 304, 320, 336, 0, 72, 80, 88), + GRADE_10(10, 327, 344, 361, 0, 77, 86, 95), + GRADE_11(11, 672, 707, 742, 7, 91, 101, 111), + GRADE_12(12, 915, 963, 1011, 9, 96, 107, 118); + + private final int gradeNumber; // 1~12 + private final int minDefense; + private final int defense; + private final int maxDefense; + private final int expectedHits; + private final int minAttack; + private final int attack; + private final int maxAttack; + + private static final SecureRandom random = new SecureRandom(); + + NpcGrade(int gradeNumber, int minDefense, int defense, int maxDefense, + int expectedHits, int minAttack, int attack, int maxAttack) { + this.gradeNumber = gradeNumber; + this.minDefense = minDefense; + this.defense = defense; + this.maxDefense = maxDefense; + this.expectedHits = expectedHits; + this.minAttack = minAttack; + this.attack = attack; + this.maxAttack = maxAttack; + } + + /** + * 1~12 등급 중 랜덤 반환 + */ + public static NpcGrade getRandomGrade() { + NpcGrade[] values = NpcGrade.values(); + return values[random.nextInt(values.length)]; + } +} From 8837a1da42d3f84d5f2df4b141528d35243d06b9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 01:38:31 +0900 Subject: [PATCH 245/527] feat: create Chapter domain --- .../com/scriptopia/demo/domain/Chapter.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/Chapter.java b/src/main/java/com/scriptopia/demo/domain/Chapter.java index 6a5693c7..5af98675 100644 --- a/src/main/java/com/scriptopia/demo/domain/Chapter.java +++ b/src/main/java/com/scriptopia/demo/domain/Chapter.java @@ -1,7 +1,21 @@ package com.scriptopia.demo.domain; + +import lombok.Getter; + +@Getter public enum Chapter { - CHAPTER1, - CHAPTER2, - CHAPTER3 + CHAPTER1(new int[]{14,16,14,12,10,9,8,7,6,4,1,1}), + CHAPTER2(new int[]{8,9,9,12,12,12,11,10,10,7,5,5}), + CHAPTER3(new int[]{4,5,6,7,9,11,13,13,12,10,6,7}); + + private final int[] npcProbabilities; + + Chapter(int[] npcProbabilities) { + this.npcProbabilities = npcProbabilities; + } + + public int[] getNpcProbabilities() { + return npcProbabilities; + } } From 9734dff9045c25267f054ef0233402859740cb87 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 02:10:14 +0900 Subject: [PATCH 246/527] feat: create NpcGrade domain --- .../com/scriptopia/demo/domain/NpcGrade.java | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java index 91884614..a913388e 100644 --- a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java +++ b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java @@ -5,48 +5,44 @@ import java.security.SecureRandom; @Getter -public enum NpnGrade { - GRADE_1(1, 67, 70, 74, 2, 16, 18, 20), - GRADE_2(2, 100, 105, 110, 3, 23, 26, 29), - GRADE_3(3, 133, 140, 147, 4, 32, 35, 39), - GRADE_4(4, 144, 152, 160, 0, 34, 38, 42), - GRADE_5(5, 179, 188, 197, 0, 42, 47, 52), - GRADE_6(6, 194, 204, 214, 0, 46, 51, 56), - GRADE_7(7, 236, 248, 260, 0, 56, 62, 68), - GRADE_8(8, 255, 268, 281, 0, 60, 67, 74), - GRADE_9(9, 304, 320, 336, 0, 72, 80, 88), - GRADE_10(10, 327, 344, 361, 0, 77, 86, 95), - GRADE_11(11, 672, 707, 742, 7, 91, 101, 111), - GRADE_12(12, 915, 963, 1011, 9, 96, 107, 118); - - private final int gradeNumber; // 1~12 - private final int minDefense; +public enum NpcGrade { + GRADE1(1, 70, 18), + GRADE2(2, 105,26), + GRADE3(3, 140,35), + GRADE4(4,152,38), + GRADE5(5, 188,47), + GRADE6(6, 204,51), + GRADE7(7, 248,62), + GRADE8(8, 268,67), + GRADE9(9, 320,80), + GRADE10(10, 344,86), + GRADE11(11, 707, 101), + GRADE12(12, 963,107); + + private final int gradeNumber; private final int defense; - private final int maxDefense; - private final int expectedHits; - private final int minAttack; private final int attack; - private final int maxAttack; private static final SecureRandom random = new SecureRandom(); - NpcGrade(int gradeNumber, int minDefense, int defense, int maxDefense, - int expectedHits, int minAttack, int attack, int maxAttack) { + NpcGrade(int gradeNumber, int defense, + int attack) { this.gradeNumber = gradeNumber; - this.minDefense = minDefense; this.defense = defense; - this.maxDefense = maxDefense; - this.expectedHits = expectedHits; - this.minAttack = minAttack; this.attack = attack; - this.maxAttack = maxAttack; } - /** - * 1~12 등급 중 랜덤 반환 - */ - public static NpcGrade getRandomGrade() { - NpcGrade[] values = NpcGrade.values(); - return values[random.nextInt(values.length)]; + // ±10% 랜덤 방어력 + public int getRandomDefense() { + int delta = (int)(defense * 0.1); + return defense - delta + random.nextInt(2 * delta + 1); + } + + // ±10% 랜덤 공격력 + public int getRandomAttack() { + int delta = (int)(attack * 0.1); + return attack - delta + random.nextInt(2 * delta + 1); } + + } From aa52b79dd224db81fb8fc9f627b98bd715430812 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 02:23:47 +0900 Subject: [PATCH 247/527] feat: create NpcGrade domain to getByGradeNumber method --- src/main/java/com/scriptopia/demo/domain/NpcGrade.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java index a913388e..b47f1cf8 100644 --- a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java +++ b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java @@ -32,6 +32,16 @@ public enum NpcGrade { this.attack = attack; } + + public static NpcGrade getByGradeNumber(int gradeNumber) { + for (NpcGrade grade : NpcGrade.values()) { + if (grade.getGradeNumber() == gradeNumber) { + return grade; + } + } + return null; + } + // ±10% 랜덤 방어력 public int getRandomDefense() { int delta = (int)(defense * 0.1); From 8db25b576e1947ca91f4224bfaf54bd247f31439 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 02:24:28 +0900 Subject: [PATCH 248/527] refactor: Grade domain --- src/main/java/com/scriptopia/demo/domain/Grade.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 1379afc9..b814f81e 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -37,7 +37,6 @@ public static Grade getRandomGradeByProbability() { return grade; } } - return LEGENDARY; } From 9bc6a92495efd7471338659829a3d1aac3324d2a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 02:29:48 +0900 Subject: [PATCH 249/527] refactor: GameSession service --- .../java/com/scriptopia/demo/service/GameSessionService.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 60dc6982..86cb40fc 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -34,7 +34,6 @@ public class GameSessionService { private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; private final UserRepository userRepository; - private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; @@ -122,7 +121,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { .bodyToMono(ExternalGameResponse.class) .block(); // -// 응답 검증 + // 응답 검증 if (externalGame == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } @@ -132,7 +131,6 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { Grade.COMMON, itemGradeDefRepository, effectGradeDefRepository - ); // GameSession Data From aad2b2ca4d3c4e54aa4c77c1320c36ac5459fe13 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 02:55:33 +0900 Subject: [PATCH 250/527] feat: create ItemType domain --- .../scriptopia/demo/domain/ItemCategory.java | 5 --- .../com/scriptopia/demo/domain/ItemType.java | 33 ++++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/domain/ItemCategory.java diff --git a/src/main/java/com/scriptopia/demo/domain/ItemCategory.java b/src/main/java/com/scriptopia/demo/domain/ItemCategory.java deleted file mode 100644 index 02d53890..00000000 --- a/src/main/java/com/scriptopia/demo/domain/ItemCategory.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.scriptopia.demo.domain; - -public enum ItemCategory { - WEAPON, ARMOR, ARTIFACT, POTION -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/ItemType.java b/src/main/java/com/scriptopia/demo/domain/ItemType.java index bc8b0524..a41a27fc 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemType.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemType.java @@ -1,8 +1,33 @@ package com.scriptopia.demo.domain; +import java.security.SecureRandom; + public enum ItemType { - WEAPON, - ARMOR, - ARTIFACT, - POTION + WEAPON(40), + ARMOR(40), + ARTIFACT(20), + POTION(0); + + + private final int dropRate; + + private static final SecureRandom random = new SecureRandom(); + + ItemType(int dropRate) { + this.dropRate = dropRate; + } + + public static ItemType getRandomItemType() { + int rand = random.nextInt(100) + 1; + int cumulative = 0; + + for (ItemType itemType : ItemType.values()) { + cumulative += itemType.dropRate; + if ( rand <= cumulative) { + return itemType; + } + } + return null; + } + } \ No newline at end of file From 49775e710747ad61a7000f3c02000c0f1e75f02d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 03:01:23 +0900 Subject: [PATCH 251/527] feat: create ChoiceEventType domain --- .../demo/domain/ChoiceEventType.java | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java index 26b1bd02..e3324409 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java @@ -1,5 +1,36 @@ package com.scriptopia.demo.domain; +import lombok.Getter; + +import java.security.SecureRandom; + + +@Getter public enum ChoiceEventType { - LIVING, NONLIVING + LIVING(50), + NONLIVING(50); + + + private final int ChoiceEventChance; + + + private static final SecureRandom random = new SecureRandom(); + + + ChoiceEventType(final int ChoiceEventChance) { + this.ChoiceEventChance = ChoiceEventChance; + } + + public ChoiceEventType getChoiceEventType() { + int rand = random.nextInt(100) + 1; + int cumulative = 0; + + for (ChoiceEventType Choicetype : ChoiceEventType.values()) { + cumulative += Choicetype.getChoiceEventChance(); + if (cumulative >= rand) { + return Choicetype; + } + } + return NONLIVING; + } } \ No newline at end of file From 528a982b87c4dd4a592b2933015332e9a7fb513b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 03:07:43 +0900 Subject: [PATCH 252/527] refactor: get random ChoiceType --- .../demo/domain/ChoiceResultType.java | 28 ++++++++++++++++++- .../demo/domain/mongo/ChoiceMongo.java | 4 +-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index fbd4d169..96d4aec2 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -1,5 +1,31 @@ package com.scriptopia.demo.domain; +import java.security.SecureRandom; + public enum ChoiceResultType { - BATTLE, REWARD, SHOP, NONE + BATTLE(40), + CHOICE(30), + SHOP(10), + NONE(50); + + private int nextEventType; + + private static final SecureRandom random = new SecureRandom(); + + ChoiceResultType(int nextEventType) { + this.nextEventType = nextEventType; + } + + + public ChoiceResultType nextResultType() { + int rand = random.nextInt(nextEventType); + int cumulative = 0; + + for(ChoiceResultType type : values()) { + if(rand == type.nextEventType) { + return type; + } + } + return NONE; + } } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index 7f279cd6..3c5e3e24 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -11,7 +11,7 @@ @NoArgsConstructor public class ChoiceMongo { private String detail; - private Stat stats; // strength, agility, intelligence, luck + private Stat stats; // STRENGTH, AGILITY, INTELLIGENCE, LUCK private Integer probability; - private ChoiceResultType resultType; // battle, reward, shop, none + private ChoiceResultType resultType; // BATTLE, SHOP, CHOICE, NONE } \ No newline at end of file From c6036c24def127c7164fcd3ae6a41c5c87a444e2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 03:08:56 +0900 Subject: [PATCH 253/527] refactor: get random ChoiceType --- .../java/com/scriptopia/demo/domain/ChoiceResultType.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index 96d4aec2..c724adc7 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -1,14 +1,17 @@ package com.scriptopia.demo.domain; +import lombok.Getter; + import java.security.SecureRandom; +@Getter public enum ChoiceResultType { BATTLE(40), CHOICE(30), SHOP(10), NONE(50); - private int nextEventType; + private final int nextEventType; private static final SecureRandom random = new SecureRandom(); @@ -22,7 +25,8 @@ public ChoiceResultType nextResultType() { int cumulative = 0; for(ChoiceResultType type : values()) { - if(rand == type.nextEventType) { + cumulative += type.getNextEventType(); + if(rand <= cumulative ) { return type; } } From 310b609e172e3bd15cfcf259750eb486308bc51c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 03:40:51 +0900 Subject: [PATCH 254/527] refactor: Spring to FastAPI requestDTO --- .../demo/dto/items/ItemDefRequest.java | 19 ++++++++++++++----- .../demo/dto/items/ItemEffectRequest.java | 12 ------------ 2 files changed, 14 insertions(+), 17 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index be862895..cd69e116 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.items; +import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; import lombok.Data; @@ -10,10 +11,18 @@ public class ItemDefRequest { private String worldView; - private String name; - private String description; - - // 🔹 아이템 효과 리스트 - private List effects; + private String location; + private ItemType category; + private Stat baseStat; + private Stat mainStat; + private Grade grade; + private List itemEffect; + private int strength; + private int agility; + private int intelligence; + private int luck; + private Long price; + private String playerTrait; + private String previousStory; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java deleted file mode 100644 index ca4cf982..00000000 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.scriptopia.demo.dto.items; - -import com.scriptopia.demo.domain.Grade; -import lombok.Data; - -@Data -public class ItemEffectRequest { - private String effectName; - private String effectDescription; - private Grade grade; - private Integer effectValue; -} \ No newline at end of file From 17f59036b7a28760604f5d663f07ad6c9cea3cb5 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 04:12:11 +0900 Subject: [PATCH 255/527] feat: create getGrade to baseStat for itemGrade --- .../demo/controller/ItemController.java | 4 +- .../com/scriptopia/demo/domain/Grade.java | 18 ++++ .../java/com/scriptopia/demo/domain/Stat.java | 3 +- .../demo/dto/items/ItemDefRequest.java | 6 +- .../demo/dto/items/ItemFastApiRequest.java | 18 ++++ .../demo/service/ItemDefService.java | 102 +++++------------- .../demo/utils/GameBalanceUtil.java | 5 - 7 files changed, 73 insertions(+), 83 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index 9e365880..3c54393e 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -16,8 +16,8 @@ public class ItemController { @PostMapping - public ResponseEntity createItem() { - ItemDefResponse savedItem = itemDefService.createItem(); + public ResponseEntity createItem(@RequestBody ItemDefRequest request) { + ItemDefResponse savedItem = itemDefService.createItem(request); return ResponseEntity.ok(savedItem); } diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index b814f81e..0f905749 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -25,6 +25,7 @@ public enum Grade { private static final SecureRandom random = new SecureRandom(); /** + * 아이템, 아이템 효과 * Grade 중 하나를 랜덤으로 반환 */ public static Grade getRandomGradeByProbability() { @@ -40,5 +41,22 @@ public static Grade getRandomGradeByProbability() { return LEGENDARY; } + /** + * 아이템 등급에 따른 기본 성능을 리턴 + * 기본 공격력에 +- 10% + */ + public static int getRandomBaseStatWeapon(Grade grade) { + int base = grade.getAttackPower(); + int delta = (int) (base * 0.1); + return base - delta + random.nextInt(2 * delta + 1); + } + + // ±10% 랜덤 방어력 + public static int getRandomBaseStatArmor(Grade grade) { + int base = grade.getDefensePower(); + int delta = (int) (base * 0.1); + return base - delta + random.nextInt(2 * delta + 1); + } + } diff --git a/src/main/java/com/scriptopia/demo/domain/Stat.java b/src/main/java/com/scriptopia/demo/domain/Stat.java index 6ff5a4b1..4cafefc1 100644 --- a/src/main/java/com/scriptopia/demo/domain/Stat.java +++ b/src/main/java/com/scriptopia/demo/domain/Stat.java @@ -15,9 +15,10 @@ public enum Stat { private static final SecureRandom random = new SecureRandom(); /** + * 메인 스탯의 경우 사용 * 무작위로 하나의 스탯을 반환 */ - public static Stat getRandomStat() { + public static Stat getRandomMainStat() { Stat[] values = Stat.values(); return values[random.nextInt(values.length)]; } diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index cd69e116..fe68fbcb 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -3,17 +3,21 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; @Data +@Builder public class ItemDefRequest { private String worldView; private String location; private ItemType category; - private Stat baseStat; + private int baseStat; private Stat mainStat; private Grade grade; private List itemEffect; diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java new file mode 100644 index 00000000..103c19c7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.dto.items; + +import com.scriptopia.demo.domain.ItemType; +import lombok.Builder; +import lombok.Data; + + +@Data +@Builder +public class ItemFastApiRequest { + + private String worldView; + private String location; + private ItemType category; + private String playerTrait; + private String previousStory; + +} diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 98be937a..6365f163 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,9 +1,6 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.EffectGradeDef; -import com.scriptopia.demo.domain.ItemDef; -import com.scriptopia.demo.domain.ItemEffect; -import com.scriptopia.demo.domain.ItemGradeDef; +import com.scriptopia.demo.domain.*; import com.scriptopia.demo.dto.develop.ItemDefResponse; import com.scriptopia.demo.dto.develop.ItemEffectResponse; import com.scriptopia.demo.dto.items.*; @@ -28,79 +25,36 @@ public class ItemDefService { private final EffectGradeDefRepository effectGradeDefRepository; @Transactional - public ItemDefResponse createItem(ItemDefRequest dto) { - // ItemGradeDef 조회 - ItemGradeDef gradeDef = itemGradeDefRepository.findById(dto.getItemGradeDefId()) - .orElseThrow(() -> new IllegalArgumentException("ItemGradeDef not found")); + public ItemDefResponse createItem(ItemDefRequest request) { + /** + * 1. 카테고리 + * 2. 등급 + * 3. 메인 스탯 + * 4. 베이스 스탯 (공격력, 체력) + * 5. 아이템 이펙트( 최대 등급 3개) + * 6. 추가 스탯 + */ + + + ItemFastApiRequest fastRequest = ItemFastApiRequest.builder() + .worldView(request.getWorldView()) + .location(request.getLocation()) + .category(ItemType.getRandomItemType()) + .baseStat() + .mainStat(Stat.getRandomMainStat()) + .grade(Grade.RARE) + .itemEffect(List.of(Grade.COMMON, Grade.UNCOMMON)) + .strength(10) + .agility(5) + .intelligence(3) + .luck(2) + .price(1000L) + .playerTrait("용맹함") + .previousStory("고대의 유산에서 발견됨") + .build(); - // ItemDef 생성 - ItemDef itemDef = new ItemDef(); - itemDef.setName(dto.getName()); - itemDef.setDescription(dto.getDescription()); - itemDef.setPicSrc(dto.getPicSrc()); - itemDef.setItemType(dto.getItemType()); - itemDef.setStat(dto.getStat()); - itemDef.setBaseStat(dto.getBaseStat()); - itemDef.setStrength(dto.getStrength()); - itemDef.setAgility(dto.getAgility()); - itemDef.setIntelligence(dto.getIntelligence()); - itemDef.setLuck(dto.getLuck()); - itemDef.setPrice(dto.getPrice()); - itemDef.setItemGradeDef(gradeDef); - itemDef.setCreatedAt(LocalDateTime.now()); - // ItemEffect 생성 - if (dto.getEffects() != null) { - for (ItemEffectRequest effectDto : dto.getEffects()) { - EffectGradeDef effectGradeDef = effectGradeDefRepository.findById(effectDto.getGrade().ordinal() + 1L) - .orElseThrow(() -> new IllegalArgumentException("EffectGradeDef not found")); - - ItemEffect effect = new ItemEffect(); - effect.setItemDef(itemDef); - effect.setEffectGradeDef(effectGradeDef); - effect.setEffectName(effectDto.getEffectName()); - effect.setEffectDescription(effectDto.getEffectDescription()); - - itemDef.getItemEffects().add(effect); - } - } - - // ItemDef 저장 (cascade로 ItemEffect도 같이 저장) - itemDefRepository.save(itemDef); - - // DTO 변환 후 반환 - return toResponse(itemDef); } - // DTO 변환 - private ItemDefResponse toResponse(ItemDef itemDef) { - ItemDefResponse response = new ItemDefResponse(); - response.setId(itemDef.getId()); - response.setName(itemDef.getName()); - response.setDescription(itemDef.getDescription()); - response.setPicSrc(itemDef.getPicSrc()); - response.setItemType(itemDef.getItemType().name()); - response.setMainStat(itemDef.getStat().name()); - response.setBaseStat(itemDef.getBaseStat()); - response.setStrength(itemDef.getStrength()); - response.setAgility(itemDef.getAgility()); - response.setIntelligence(itemDef.getIntelligence()); - response.setLuck(itemDef.getLuck()); - response.setPrice(itemDef.getPrice()); - response.setCreatedAt(itemDef.getCreatedAt()); - List effects = itemDef.getItemEffects().stream() - .map(effect -> { - ItemEffectResponse eResp = new ItemEffectResponse(); - eResp.setEffectName(effect.getEffectName()); - eResp.setEffectDescription(effect.getEffectDescription()); - eResp.setGrade(effect.getEffectGradeDef().getGrade().name()); - return eResp; - }) - .collect(Collectors.toList()); - - response.setEffects(effects); - - return response; - } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index e5bd53ba..b33847c4 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -1,13 +1,8 @@ package com.scriptopia.demo.utils; import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; import java.security.SecureRandom; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; public class GameBalanceUtil { static SecureRandom secureRandom = new SecureRandom(); From de08df1778390e1de37494f93ba2c03019d5ef70 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 04:18:41 +0900 Subject: [PATCH 256/527] refactor: change each request --- .../com/scriptopia/demo/dto/items/ItemDefRequest.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index fe68fbcb..c9163c1b 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -17,16 +17,8 @@ public class ItemDefRequest { private String worldView; private String location; private ItemType category; - private int baseStat; - private Stat mainStat; - private Grade grade; - private List itemEffect; - private int strength; - private int agility; - private int intelligence; - private int luck; - private Long price; private String playerTrait; private String previousStory; + } \ No newline at end of file From 8566f5d16ec50a5bd5886070cfe4ec55252ed740 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 04:20:14 +0900 Subject: [PATCH 257/527] refactor: change each dto --- .../demo/dto/items/ItemFastApiRequest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java index 103c19c7..3d4634ac 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java @@ -1,9 +1,13 @@ package com.scriptopia.demo.dto.items; +import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.Stat; import lombok.Builder; import lombok.Data; +import java.util.List; + @Data @Builder @@ -12,6 +16,15 @@ public class ItemFastApiRequest { private String worldView; private String location; private ItemType category; + private int baseStat; + private Stat mainStat; + private Grade grade; + private List itemEffect; + private int strength; + private int agility; + private int intelligence; + private int luck; + private Long price; private String playerTrait; private String previousStory; From a14035657b1e366ea65e8c0ef5a3b2f157573354 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 13:57:14 +0900 Subject: [PATCH 258/527] feat: create method to getRandomItemStatByGrade --- .../demo/utils/GameBalanceUtil.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index b33847c4..18391d93 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -24,4 +24,31 @@ public static int[] initItemStat(Grade grade) { } return stats; } + + public static int[] getRandomItemStatsByGrade(Grade grade) { + int min = 0, max = 0; + switch (grade) { + case COMMON -> { min = 0; max = 3; } + case UNCOMMON -> { min = 1; max = 4; } + case RARE -> { min = 2; max = 5; } + case EPIC -> { min = 4; max = 7; } + case LEGENDARY -> { min = 5; max = 8; } + } + + // 총합 랜덤 결정 + int totalPoints = secureRandom.nextInt(max - min + 1) + min; + + // 배열 생성 (0: STR, 1: AGI, 2: INT, 3: LUCK) + int[] stats = new int[4]; + + // 랜덤 분배 + for (int i = 0; i < totalPoints; i++) { + stats[secureRandom.nextInt(4)]++; + } + + return stats; + } + + + } From 73ac499c666d83fd11b3eb0d64f5a610ddb39168 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 14:01:28 +0900 Subject: [PATCH 259/527] refactor: delete initItemRanomSate method and change method by getRandomItemStatByGrade --- .../demo/utils/GameBalanceUtil.java | 24 ++++--------------- .../scriptopia/demo/utils/InitGameData.java | 3 ++- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 18391d93..c8e03e99 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -7,24 +7,11 @@ public class GameBalanceUtil { static SecureRandom secureRandom = new SecureRandom(); - public static int[] initItemStat(Grade grade) { - int[] stats = new int[4]; // str,agi,int,luk - int itemBaseStat = 0; - - switch (grade) { - case COMMON -> itemBaseStat = secureRandom.nextInt(4); - case UNCOMMON -> itemBaseStat = secureRandom.nextInt(4) + 1; - case RARE -> itemBaseStat = secureRandom.nextInt(4) + 2; - case EPIC -> itemBaseStat = secureRandom.nextInt(4) + 4; - case LEGENDARY -> itemBaseStat = secureRandom.nextInt(4) + 5; - } - - for(int i = 0; i < itemBaseStat; i++){ - stats[secureRandom.nextInt(4)] += 1; - } - return stats; - } + /** + * @param grade + * @return (0: STR, 1: AGI, 2: INT, 3: LUCK) + */ public static int[] getRandomItemStatsByGrade(Grade grade) { int min = 0, max = 0; switch (grade) { @@ -35,13 +22,10 @@ public static int[] getRandomItemStatsByGrade(Grade grade) { case LEGENDARY -> { min = 5; max = 8; } } - // 총합 랜덤 결정 int totalPoints = secureRandom.nextInt(max - min + 1) + min; - // 배열 생성 (0: STR, 1: AGI, 2: INT, 3: LUCK) int[] stats = new int[4]; - // 랜덤 분배 for (int i = 0; i < totalPoints; i++) { stats[secureRandom.nextInt(4)]++; } diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 95d73bef..71390a48 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -70,7 +70,8 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.baseStat = (int) Math.floor(Grade.COMMON.getAttackPower() * (1 + attackRate / 100.0)); - int[] stats = GameBalanceUtil.initItemStat(grade); + // 배열 생성 (0: STR, 1: AGI, 2: INT, 3: LUCK) + int[] stats = GameBalanceUtil.getRandomItemStatsByGrade(grade); this.itemStr = stats[0]; this.itemAgi = stats[1]; this.itemInt = stats[2]; From 9377ce1f0dc5f8ff0266b3e7a872195ba8c83249 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 14:02:26 +0900 Subject: [PATCH 260/527] feat: getRandomBaseStat by ItemTye and Grade --- src/main/java/com/scriptopia/demo/domain/Grade.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/Grade.java b/src/main/java/com/scriptopia/demo/domain/Grade.java index 0f905749..0b6c6783 100644 --- a/src/main/java/com/scriptopia/demo/domain/Grade.java +++ b/src/main/java/com/scriptopia/demo/domain/Grade.java @@ -42,9 +42,18 @@ public static Grade getRandomGradeByProbability() { } /** - * 아이템 등급에 따른 기본 성능을 리턴 + * 아이템 종류, 등급에 따른 기본 성능을 리턴 * 기본 공격력에 +- 10% */ + public static int getRandomBaseStat(ItemType itemType, Grade grade) { + if (itemType == ItemType.WEAPON){ + return getRandomBaseStatWeapon(grade); + } + return getRandomBaseStatArmor(grade); + } + + + // ±10% 랜덤 공격력 public static int getRandomBaseStatWeapon(Grade grade) { int base = grade.getAttackPower(); int delta = (int) (base * 0.1); From 4a458d688fb80f3099d737cad4c364f0262dc500 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 15:06:48 +0900 Subject: [PATCH 261/527] refactor: change itemPrice and itemEffectPrice --- .../demo/config/DataLoaderConfig.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index 4671b545..651ee7c6 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -21,18 +21,18 @@ public class DataLoaderConfig { public ApplicationRunner dataLoader() { return args -> { // ItemGradeDef 기본 데이터 - saveItemGradeIfNotExists(Grade.COMMON, 1.0, 100L); - saveItemGradeIfNotExists(Grade.UNCOMMON, 1.0, 200L); - saveItemGradeIfNotExists(Grade.RARE, 1.0, 500L); - saveItemGradeIfNotExists(Grade.EPIC, 1.0, 1000L); - saveItemGradeIfNotExists(Grade.LEGENDARY, 1.0, 2000L); + saveItemGradeIfNotExists(Grade.COMMON, 1.0, 100); + saveItemGradeIfNotExists(Grade.UNCOMMON, 1.0, 150L); + saveItemGradeIfNotExists(Grade.RARE, 1.0, 200L); + saveItemGradeIfNotExists(Grade.EPIC, 1.0, 250L); + saveItemGradeIfNotExists(Grade.LEGENDARY, 1.0, 300L); // EffectGradeDef 기본 데이터 - saveEffectGradeIfNotExists(Grade.COMMON, 100L, 0.1); - saveEffectGradeIfNotExists(Grade.UNCOMMON, 200L, 0.15); - saveEffectGradeIfNotExists(Grade.RARE, 500L, 0.2); - saveEffectGradeIfNotExists(Grade.EPIC, 1000L, 0.25); - saveEffectGradeIfNotExists(Grade.LEGENDARY, 2000L, 0.3); + saveEffectGradeIfNotExists(Grade.COMMON, 10L, 0.1); + saveEffectGradeIfNotExists(Grade.UNCOMMON, 20L, 0.15); + saveEffectGradeIfNotExists(Grade.RARE, 50L, 0.2); + saveEffectGradeIfNotExists(Grade.EPIC, 80L, 0.25); + saveEffectGradeIfNotExists(Grade.LEGENDARY, 100L, 0.3); }; } From 6969bbf16648ba3fca7dbbd2cd7f51e19a8b2b68 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 15:24:07 +0900 Subject: [PATCH 262/527] feat: create getItemPriceByGrade method refactor: change initItemLogic --- .../demo/utils/GameBalanceUtil.java | 38 ++++++++++++++++++- .../scriptopia/demo/utils/InitGameData.java | 13 +++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index c8e03e99..7567087c 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -1,12 +1,21 @@ package com.scriptopia.demo.utils; import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemGradeDefRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; import java.security.SecureRandom; +import java.util.List; +@Component +@RequiredArgsConstructor public class GameBalanceUtil { - static SecureRandom secureRandom = new SecureRandom(); + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; + static SecureRandom secureRandom = new SecureRandom(); /** * @param grade @@ -34,5 +43,32 @@ public static int[] getRandomItemStatsByGrade(Grade grade) { } + /** + * @param itemGradePrice + * @param effectGradeList + * @return Long + */ + public static Long getItemPriceByGrade(Long itemGradePrice, List effectGradeList) { + return getRandomItemPriceByGrade(itemGradePrice) + getRandomItemPriceByGrade(effectGradeList); + } + + + public static Long getRandomItemPriceByGrade(Long itemGradePrice) { + int priceRate = secureRandom.nextInt(21) - 10; + Long itemPrice = 0L; + itemPrice += (long) Math.floor(itemGradePrice * (1 + priceRate / 100.0)); + return itemPrice; + } + + public static Long getRandomItemPriceByGrade(List effectGradeList) { + int priceRate = secureRandom.nextInt(21) - 10; + + Long effectPrice = 0L; + for (Long grade : effectGradeList) { + effectPrice += (long) Math.floor(grade * (1 + priceRate / 100.0)); + } + + return effectPrice; + } } diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 71390a48..c0783662 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -77,11 +77,16 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.itemInt = stats[2]; this.itemLuk = stats[3]; - int priceRate = secureRandom.nextInt(21) - 10; - Long gradePrice = (long) itemGradeDefRepository.findPriceByGrade(grade); - Long effectPrice = (long) Math.floor(effectGradeDefRepository.findPriceByGrade(grade) * (1 + priceRate / 100.0)); - this.itemPrice = gradePrice + effectPrice; + + List itemEffectList = new ArrayList<>(); + for (int i=0; i<=3; i+=1){ + Grade effectGrade = Grade.getRandomGradeByProbability(); + itemEffectList.add(effectGradeDefRepository.findPriceByGrade(effectGrade)); + } + Long gradePrice = itemGradeDefRepository.findPriceByGrade(grade); + + this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice , itemEffectList); } From 3e7042fbdb3148a94d2fed542dcb3e3c006fd5ed Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 15:25:10 +0900 Subject: [PATCH 263/527] refactor: change return type Long --- .../com/scriptopia/demo/repository/ItemGradeDefRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java index 1547636e..b2fce9f3 100644 --- a/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/ItemGradeDefRepository.java @@ -14,7 +14,7 @@ public interface ItemGradeDefRepository extends JpaRepository findByGrade(Grade grade); @Query("SELECT igm.price FROM ItemGradeDef igm WHERE igm.grade = :grade") - Integer findPriceByGrade(@Param("grade") Grade grade); + Long findPriceByGrade(@Param("grade") Grade grade); } From 97a2835772ad5f83c5cbcfbb5324678f6fec29d1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 16:51:54 +0900 Subject: [PATCH 264/527] feat: create EffectProbability and inner method getRandomEffectGradeByWeaponGrade --- .../demo/domain/EffectProbability.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/EffectProbability.java diff --git a/src/main/java/com/scriptopia/demo/domain/EffectProbability.java b/src/main/java/com/scriptopia/demo/domain/EffectProbability.java new file mode 100644 index 00000000..4083b2ec --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/EffectProbability.java @@ -0,0 +1,75 @@ +package com.scriptopia.demo.domain; + +import lombok.Getter; + +import java.security.SecureRandom; + +@Getter +public enum EffectProbability { + + COMMON(80, 16, 4, 0, 0, 0), + UNCOMMON(75, 7, 12, 5, 0, 0), + RARE(65, 5, 5, 17, 7, 0), + EPIC(60, 4, 4, 4, 20, 8), + LEGENDARY(55, 1, 2, 7, 23, 12); + + private final int nullProb; + private final int commonProb; + private final int uncommonProb; + private final int rareProb; + private final int epicProb; + private final int legendaryProb; + + private static final SecureRandom random = new SecureRandom(); + + + + EffectProbability(int n, int c, int u, int r, int e, int l) { + this.nullProb = n; + this.commonProb = c; + this.uncommonProb = u; + this.rareProb = r; + this.epicProb = e; + this.legendaryProb = l; + } + + /** + * @param weaponGrade + * @return + */ + public EffectProbability getRandomEffectGradeByWeaponGrade(Grade weaponGrade) { + EffectProbability prob = null; + + switch (weaponGrade) { + case COMMON -> prob = EffectProbability.COMMON; + case UNCOMMON -> prob = EffectProbability.UNCOMMON; + case RARE -> prob = EffectProbability.RARE; + case EPIC -> prob = EffectProbability.EPIC; + case LEGENDARY -> prob = EffectProbability.LEGENDARY; + } + + int[] probabilities = { + prob.getNullProb(), + prob.getCommonProb(), + prob.getUncommonProb(), + prob.getRareProb(), + prob.getEpicProb(), + prob.getLegendaryProb() + }; + + EffectProbability[] grades = {null, EffectProbability.COMMON, EffectProbability.UNCOMMON, EffectProbability.RARE, EffectProbability.EPIC, EffectProbability.LEGENDARY}; + + int rand = random.nextInt(100) + 1; // 1~100 + int cumulative = 0; + + for (int i = 0; i < probabilities.length; i++) { + cumulative += probabilities[i]; + if (rand <= cumulative) { + return grades[i]; + } + } + + return null; + } + +} From fb20259cf9fc84ba565856613ee69f6f1173fcf7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 17:14:43 +0900 Subject: [PATCH 265/527] refactor: remapping --- .../com/scriptopia/demo/domain/EffectProbability.java | 2 +- .../java/com/scriptopia/demo/utils/InitGameData.java | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/EffectProbability.java b/src/main/java/com/scriptopia/demo/domain/EffectProbability.java index 4083b2ec..74b532ca 100644 --- a/src/main/java/com/scriptopia/demo/domain/EffectProbability.java +++ b/src/main/java/com/scriptopia/demo/domain/EffectProbability.java @@ -37,7 +37,7 @@ public enum EffectProbability { * @param weaponGrade * @return */ - public EffectProbability getRandomEffectGradeByWeaponGrade(Grade weaponGrade) { + public static EffectProbability getRandomEffectGradeByWeaponGrade(Grade weaponGrade) { EffectProbability prob = null; switch (weaponGrade) { diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index c0783662..d06168d9 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -1,8 +1,6 @@ package com.scriptopia.demo.utils; -import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemType; -import com.scriptopia.demo.domain.Stat; +import com.scriptopia.demo.domain.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; import lombok.*; @@ -80,10 +78,7 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep List itemEffectList = new ArrayList<>(); - for (int i=0; i<=3; i+=1){ - Grade effectGrade = Grade.getRandomGradeByProbability(); - itemEffectList.add(effectGradeDefRepository.findPriceByGrade(effectGrade)); - } + itemEffectList.add(effectGradeDefRepository.findPriceByEffectGrade(EffectProbability.COMMON)); Long gradePrice = itemGradeDefRepository.findPriceByGrade(grade); this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice , itemEffectList); From 95c7747e64b125e692514864cbd9d808c93cf71f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 17:15:15 +0900 Subject: [PATCH 266/527] feat: create findPriceByEffectGrade by EffectProbability --- .../scriptopia/demo/repository/EffectGradeDefRepository.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index d8fca7c2..a685884d 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.EffectGradeDef; +import com.scriptopia.demo.domain.EffectProbability; import com.scriptopia.demo.domain.Grade; import org.springframework.data.jpa.repository.JpaRepository; @@ -13,5 +14,7 @@ public interface EffectGradeDefRepository extends JpaRepository findByGrade(Grade grade); @Query("SELECT egd.price FROM EffectGradeDef egd WHERE egd.grade = :grade") - Integer findPriceByGrade(@Param("grade") Grade grade); + Long findPriceByGrade(@Param("grade") Grade grade); + + Long findPriceByEffectGrade(EffectProbability effectGrade); } From 17b9cec601c1ab4ff8e5e9af2ddf94d1fcb796ad Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 17:23:36 +0900 Subject: [PATCH 267/527] feat: create ItemFastApiResponse --- .../demo/dto/items/ItemFastApiRequest.java | 3 ++- .../demo/dto/items/ItemFastApiResponse.java | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java index 3d4634ac..b916fdd0 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.items; +import com.scriptopia.demo.domain.EffectProbability; import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; @@ -19,7 +20,7 @@ public class ItemFastApiRequest { private int baseStat; private Stat mainStat; private Grade grade; - private List itemEffect; + private List itemEffect; private int strength; private int agility; private int intelligence; diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java new file mode 100644 index 00000000..f4389499 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java @@ -0,0 +1,25 @@ +package com.scriptopia.demo.dto.items; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ItemFastApiResponse { + + private String itemName; + private String itemDescription; + private List itemEffect; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ItemEffect { + private String itemEffectName; + private String itemEffectDescription; + } +} \ No newline at end of file From 1f7812eb8bfc68cc2f6f0763428a48564ff518a2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 17:24:37 +0900 Subject: [PATCH 268/527] refactor: blank line --- .../java/com/scriptopia/demo/service/GameSessionService.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 86cb40fc..ee4fe9bd 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -17,14 +17,11 @@ import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Random; @Service @RequiredArgsConstructor From 8ee1e3f9bf596eb8d53eece6dbb44fce75164804 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 17:58:09 +0900 Subject: [PATCH 269/527] feat: create EffectAdapter adapter --- .../demo/adapter/EffectAdapter.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java diff --git a/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java b/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java new file mode 100644 index 00000000..fb203552 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java @@ -0,0 +1,19 @@ +package com.scriptopia.demo.adapter; + +import com.scriptopia.demo.domain.EffectProbability; +import com.scriptopia.demo.domain.Grade; + +public class EffectAdapter { + + public static Grade toGrade(EffectProbability effectProb) { + if (effectProb == null) return null; + + return switch (effectProb) { + case COMMON -> Grade.COMMON; + case UNCOMMON -> Grade.UNCOMMON; + case RARE -> Grade.RARE; + case EPIC -> Grade.EPIC; + case LEGENDARY -> Grade.LEGENDARY; + }; + } +} From 597255eec9e66daf878eefaa0f5de7ffd0e23e8c Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 6 Sep 2025 18:21:05 +0900 Subject: [PATCH 270/527] getGameSession refactor --- .../com/scriptopia/demo/exception/ErrorCode.java | 1 + .../demo/repository/GameSessionRepository.java | 5 ++++- .../demo/service/GameSessionService.java | 14 +++++--------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 62551b9c..945c997a 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -65,6 +65,7 @@ public enum ErrorCode { E_404_SETTLEMENT_NOT_FOUND("E404004","정산 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), E_404_SHARED_GAME_NOT_FOUND("E404005", "공유된 게임을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), + E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), //409 Conflict diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index c4de5b12..c5099583 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -2,6 +2,8 @@ import com.scriptopia.demo.domain.GameSession; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -13,5 +15,6 @@ public interface GameSessionRepository extends JpaRepository boolean existsByUser_Id(Long userId); - + @Query("select g from GameSession g join g.user u where u.id = :userId") + Optional findByMongoId(@Param("userId") Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 60dc6982..d5907095 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -42,15 +42,11 @@ public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - var sessions = gameSessionRepository.findAllByUser_Id(user.getId()); - var dtos = sessions.stream().map(s -> { - var dto = new GameSessionResponse(); - dto.setId(s.getId()); - dto.setSessionId(s.getMongoId()); - return dto; - }).toList(); - - return ResponseEntity.ok(dtos); + + GameSession sessions = gameSessionRepository.findByMongoId(user.getId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND)); + + return ResponseEntity.ok(sessions); } @Transactional From 529c4ec0c145f3c1cda6952f54efc280ead952e4 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 6 Sep 2025 19:29:21 +0900 Subject: [PATCH 271/527] feature duplcated gamesession gameSession service --- .../scriptopia/demo/exception/ErrorCode.java | 1 + .../repository/GameSessionRepository.java | 5 ++-- .../demo/service/GameSessionService.java | 25 +++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 945c997a..baf3cc7b 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -66,6 +66,7 @@ public enum ErrorCode { E_404_SHARED_GAME_NOT_FOUND("E404005", "공유된 게임을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), //409 Conflict diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index c5099583..de7237f7 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -11,10 +11,11 @@ public interface GameSessionRepository extends JpaRepository { Optional findByUser_IdAndMongoId(Long userId, String mongoId); - List findAllByUser_Id(Long userId); - boolean existsByUser_Id(Long userId); @Query("select g from GameSession g join g.user u where u.id = :userId") Optional findByMongoId(@Param("userId") Long userId); + + @Query("select case when (Count(g) > 0) then true else false end from GameSession g join g.user u where u.id = :userId") + boolean existsByUserId(@Param("userid") Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d5907095..eb95e635 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -34,10 +34,21 @@ public class GameSessionService { private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; private final UserRepository userRepository; - private final RestTemplate restTemplate; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; + public boolean duplcatedGameSession(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + boolean game = gameSessionRepository.existsByUserId(user.getId()); + + if(game) { + return true; + } + else return false; + } + public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); @@ -53,11 +64,15 @@ public ResponseEntity getGameSession(Long userid) { public ResponseEntity saveGameSession(Long userId, String sessionId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + boolean game = gameSessionRepository.existsByUserId(user.getId()); - GameSession gameSession = new GameSession(); - gameSession.setUser(user); - gameSession.setMongoId(sessionId); - return ResponseEntity.ok(gameSessionRepository.save(gameSession)); + if(!game) { + GameSession gameSession = new GameSession(); + gameSession.setUser(user); + gameSession.setMongoId(sessionId); + return ResponseEntity.ok(gameSessionRepository.save(gameSession)); + } + else throw new CustomException(ErrorCode.E_404_Duplicated_Game_Session); } @Transactional From 30f731fe71c9db1f026926010de73c94bb5acf1c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:26:58 +0900 Subject: [PATCH 272/527] refactor: delete DataLoaderConfig --- .../demo/config/DataLoaderConfig.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index 651ee7c6..717800ea 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -1,8 +1,5 @@ package com.scriptopia.demo.config; -import com.scriptopia.demo.domain.EffectGradeDef; -import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.domain.ItemGradeDef; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; import lombok.RequiredArgsConstructor; @@ -20,39 +17,7 @@ public class DataLoaderConfig { @Bean public ApplicationRunner dataLoader() { return args -> { - // ItemGradeDef 기본 데이터 - saveItemGradeIfNotExists(Grade.COMMON, 1.0, 100); - saveItemGradeIfNotExists(Grade.UNCOMMON, 1.0, 150L); - saveItemGradeIfNotExists(Grade.RARE, 1.0, 200L); - saveItemGradeIfNotExists(Grade.EPIC, 1.0, 250L); - saveItemGradeIfNotExists(Grade.LEGENDARY, 1.0, 300L); - // EffectGradeDef 기본 데이터 - saveEffectGradeIfNotExists(Grade.COMMON, 10L, 0.1); - saveEffectGradeIfNotExists(Grade.UNCOMMON, 20L, 0.15); - saveEffectGradeIfNotExists(Grade.RARE, 50L, 0.2); - saveEffectGradeIfNotExists(Grade.EPIC, 80L, 0.25); - saveEffectGradeIfNotExists(Grade.LEGENDARY, 100L, 0.3); }; } - - private void saveItemGradeIfNotExists(Grade grade, double weight, long price) { - itemGradeDefRepository.findByGrade(grade).orElseGet(() -> { - ItemGradeDef def = new ItemGradeDef(); - def.setGrade(grade); - def.setWeight(weight); - def.setPrice(price); - return itemGradeDefRepository.save(def); - }); - } - - private void saveEffectGradeIfNotExists(Grade grade, long price, double weight) { - effectGradeDefRepository.findByGrade(grade).orElseGet(() -> { - EffectGradeDef def = new EffectGradeDef(); - def.setGrade(grade); - def.setPrice(price); - def.setWeight(weight); - return effectGradeDefRepository.save(def); - }); - } } \ No newline at end of file From 8abf5ee1952e70f717e8a16b05ceec194077631e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:30:05 +0900 Subject: [PATCH 273/527] refactor: change mongoDB collectionName to carmelCase --- .../com/scriptopia/demo/domain/mongo/GameSessionMongo.java | 2 +- .../java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 00a68bf4..0eeb1876 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -9,7 +9,7 @@ import java.util.List; @Data -@Document(collection = "game_sessions") +@Document(collection = "gameSessions") @Builder @AllArgsConstructor @NoArgsConstructor diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java index 5273b5fe..b5632235 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemDefMongo.java @@ -3,11 +3,13 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.ItemType; import com.scriptopia.demo.domain.Stat; -import jakarta.persistence.Id; import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; import java.util.List; +@Document(collection = "itemDefMongo") @Data @Builder @AllArgsConstructor From 44cd6d2f0830886932542712e86ee21050b39a81 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:31:40 +0900 Subject: [PATCH 274/527] refactor: AuctionRepository and EffectGradeDefRepository --- .../java/com/scriptopia/demo/repository/AuctionRepository.java | 2 +- .../scriptopia/demo/repository/EffectGradeDefRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 01067358..922825ae 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -39,7 +39,7 @@ WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) AND (:grade IS NULL OR id.itemGradeDef.grade = :grade) AND (:minPrice IS NULL OR a.price >= :minPrice) AND (:maxPrice IS NULL OR a.price <= :maxPrice) - AND (:stat IS NULL OR id.stat = :stat) + AND (:stat IS NULL OR id.mainStat = :stat) AND ( :effectGrades IS NULL OR EXISTS ( diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index a685884d..c926503f 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -16,5 +16,5 @@ public interface EffectGradeDefRepository extends JpaRepository Date: Sat, 6 Sep 2025 20:32:29 +0900 Subject: [PATCH 275/527] refactor: rename stat to mainStat carmelCase --- src/main/java/com/scriptopia/demo/domain/ItemDef.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index 1d1a802c..e3d935c0 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -13,7 +13,8 @@ @Setter public class ItemDef { - @Id @GeneratedValue + @Id + @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) @@ -36,7 +37,7 @@ public class ItemDef { private Integer luck; @Enumerated(EnumType.STRING) - private Stat stat; + private Stat mainStat; private LocalDateTime createdAt; From d4c5f606fcff6f123fc7089578ea3116bb1e72b1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:33:41 +0900 Subject: [PATCH 276/527] refactor: rename stat To mainStat --- src/main/java/com/scriptopia/demo/service/AuctionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index e96828b0..7117ba32 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -331,7 +331,7 @@ public MySaleItemResponse getMySaleItems(Long userId, MySaleItemRequest requestD auction.getUserItem().getItemDef().getName(), auction.getUserItem().getItemDef().getItemGradeDef().getGrade().name(), auction.getUserItem().getItemDef().getItemType().name(), - auction.getUserItem().getItemDef().getStat().name(), + auction.getUserItem().getItemDef().getMainStat().name(), auction.getUserItem().getItemDef().getPicSrc() ) )).toList(); From 073fabe463a81b107d4e989f4da3cf9e4ee999f1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:34:45 +0900 Subject: [PATCH 277/527] refactor: rename stat to mainStat --- .../java/com/scriptopia/demo/dto/develop/ItemDefResponse.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java b/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java index 65fba5eb..ca07ea68 100644 --- a/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java @@ -21,6 +21,5 @@ public class ItemDefResponse { private Integer luck; private Long price; private LocalDateTime createdAt; - private List effects; } \ No newline at end of file From a261582148e625732dbf370b5890b7f0e12fb2fe Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:35:46 +0900 Subject: [PATCH 278/527] refactor: delete ItemDefRequest variation --- src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index c9163c1b..88d7fcc9 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -16,9 +16,7 @@ public class ItemDefRequest { private String worldView; private String location; - private ItemType category; private String playerTrait; private String previousStory; - } \ No newline at end of file From 91faa6badedff41f52263aa5114767fc15579c83 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:36:59 +0900 Subject: [PATCH 279/527] feat: create item and save mongo, rdb --- .../demo/controller/ItemController.java | 5 +- .../demo/service/ItemDefService.java | 144 ++++++++++++++++-- 2 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index 3c54393e..f12e8a29 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.dto.develop.ItemDefResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.service.ItemDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -16,8 +17,8 @@ public class ItemController { @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest request) { - ItemDefResponse savedItem = itemDefService.createItem(request); + public ResponseEntity createItem(@RequestBody ItemDefRequest request) { + ItemFastApiResponse savedItem = itemDefService.createItem(request); return ResponseEntity.ok(savedItem); } diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 6365f163..9148f51a 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,17 +1,25 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.adapter.EffectAdapter; import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.domain.mongo.ItemDefMongo; +import com.scriptopia.demo.domain.mongo.ItemEffectMongo; import com.scriptopia.demo.dto.develop.ItemDefResponse; import com.scriptopia.demo.dto.develop.ItemEffectResponse; import com.scriptopia.demo.dto.items.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; +import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; +import com.scriptopia.demo.utils.GameBalanceUtil; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -23,9 +31,11 @@ public class ItemDefService { private final ItemDefRepository itemDefRepository; private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; + private final ItemDefMongoRepository itemDefMongoRepository; - @Transactional - public ItemDefResponse createItem(ItemDefRequest request) { + + @Transactional(readOnly = false) + public ItemFastApiResponse createItem(ItemDefRequest request) { /** * 1. 카테고리 * 2. 등급 @@ -34,27 +44,133 @@ public ItemDefResponse createItem(ItemDefRequest request) { * 5. 아이템 이펙트( 최대 등급 3개) * 6. 추가 스탯 */ + ItemType itemCategory = ItemType.getRandomItemType(); + Grade itemGrade = Grade.getRandomGradeByProbability(); + int baseStat = Grade.getRandomBaseStat(itemCategory, itemGrade); + Stat mainStat = Stat.getRandomMainStat(); + int[] additionalStats = GameBalanceUtil.getRandomItemStatsByGrade(itemGrade); // strength, agility, intelligence, luck + + + List effectGrades = new ArrayList<>(); + List effectGradesList = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + EffectProbability effectGrade = EffectProbability.getRandomEffectGradeByWeaponGrade(itemGrade); + if (effectGrade != null) { + effectGrades.add(effectGrade); + effectGradesList.add(effectGradeDefRepository.findPriceByGrade(effectGrade)); + } + } + + Long gradeGradePrice = itemGradeDefRepository.findPriceByGrade(itemGrade); + Long itemPrice = GameBalanceUtil.getItemPriceByGrade(gradeGradePrice, effectGradesList); + + + ItemFastApiRequest fastRequest = ItemFastApiRequest.builder() .worldView(request.getWorldView()) .location(request.getLocation()) - .category(ItemType.getRandomItemType()) - .baseStat() - .mainStat(Stat.getRandomMainStat()) - .grade(Grade.RARE) - .itemEffect(List.of(Grade.COMMON, Grade.UNCOMMON)) - .strength(10) - .agility(5) - .intelligence(3) - .luck(2) - .price(1000L) - .playerTrait("용맹함") - .previousStory("고대의 유산에서 발견됨") + .category(itemCategory) + .baseStat(baseStat) + .mainStat(mainStat) + .grade(itemGrade) + .itemEffect(effectGrades) + .strength(additionalStats[0]) + .agility(additionalStats[1]) + .intelligence(additionalStats[2]) + .luck(additionalStats[3]) + .price(itemPrice) + .playerTrait(request.getPlayerTrait()) + .previousStory(request.getPreviousStory()) + .build(); + + + // 추후 config 에서 통합관리 + WebClient client = WebClient.builder() + .baseUrl("http://localhost:8000") // FastAPI 서버 주소 + .build(); + + + ItemFastApiResponse response = client.post() + .uri("/games/item") // FastAPI 엔드포인트 + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(fastRequest) // 생성한 ItemFastApiRequest 객체 + .retrieve() + .bodyToMono(ItemFastApiResponse.class) + .block(); // 블로킹 호출 (간단 테스트용) + + System.out.println(response ); + + + List mongoEffects = new ArrayList<>(); + List apiEffects = response.getItemEffect(); + + for (int i = 0; i < apiEffects.size(); i++) { + ItemFastApiResponse.ItemEffect apiEffect = apiEffects.get(i); + EffectProbability effectGrade = i < effectGrades.size() ? effectGrades.get(i) : null; + + mongoEffects.add(ItemEffectMongo.builder() + .grade(effectGrade != null ? EffectAdapter.toGrade(effectGrade) : Grade.COMMON) + .itemEffectName(apiEffect.getItemEffectName()) + .itemEffectDescription(apiEffect.getItemEffectDescription()) + .build()); + } + + System.out.println("mongoEffects = " + mongoEffects ); + + + ItemDefMongo itemDefMongo = ItemDefMongo.builder() + .itemPicSrc("test link") + .name(response.getItemName()) + .description(response.getItemDescription()) + .category(itemCategory) + .baseStat(baseStat) + .itemEffect(mongoEffects) + .strength(additionalStats[0]) + .agility(additionalStats[1]) + .intelligence(additionalStats[2]) + .luck(additionalStats[3]) + .mainStat(mainStat) + .grade(itemGrade) + .price(itemPrice) .build(); + itemDefMongoRepository.save(itemDefMongo); + + ItemDef itemDefRdb = new ItemDef(); + itemDefRdb.setName(itemDefMongo.getName()); + itemDefRdb.setDescription(itemDefMongo.getDescription()); + itemDefRdb.setItemGradeDef(itemGradeDefRepository.findByGrade(itemGrade).get()); + itemDefRdb.setPicSrc(itemDefMongo.getItemPicSrc()); + itemDefRdb.setItemType(itemDefMongo.getCategory()); + itemDefRdb.setBaseStat(itemDefMongo.getBaseStat()); + itemDefRdb.setStrength(itemDefMongo.getStrength()); + itemDefRdb.setAgility(itemDefMongo.getAgility()); + itemDefRdb.setIntelligence(itemDefMongo.getIntelligence()); + itemDefRdb.setLuck(itemDefMongo.getLuck()); + itemDefRdb.setMainStat(itemDefMongo.getMainStat()); + itemDefRdb.setPrice(itemDefMongo.getPrice()); + itemDefRdb.setCreatedAt(LocalDateTime.now()); + + List rdbEffects = new ArrayList<>(); + for (ItemEffectMongo effectMongo : itemDefMongo.getItemEffect()) { + ItemEffect effect = new ItemEffect(); + effect.setItemDef(itemDefRdb); + effect.setEffectName(effectMongo.getItemEffectName()); + effect.setEffectDescription(effectMongo.getItemEffectDescription()); + effect.setEffectGradeDef(effectGradeDefRepository.findByGrade(effectMongo.getGrade()).get()); + rdbEffects.add(effect); + } + itemDefRdb.setItemEffects(rdbEffects); + + itemDefRepository.save(itemDefRdb); + + return response; } + } \ No newline at end of file From c15ac5e3b1bcb8e94df90e4fb4d07b57729a2291 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:37:39 +0900 Subject: [PATCH 280/527] refactor: change create random logic --- src/main/java/com/scriptopia/demo/utils/InitGameData.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index d06168d9..a54b3ead 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -76,9 +76,8 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.itemLuk = stats[3]; - List itemEffectList = new ArrayList<>(); - itemEffectList.add(effectGradeDefRepository.findPriceByEffectGrade(EffectProbability.COMMON)); + itemEffectList.add(effectGradeDefRepository.findPriceByGrade(EffectProbability.COMMON)); Long gradePrice = itemGradeDefRepository.findPriceByGrade(grade); this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice , itemEffectList); From 90015d070f9955235b64c6f1a87a27ea3d312da2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 20:52:06 +0900 Subject: [PATCH 281/527] refactor: delete blank line --- .../demo/controller/ItemController.java | 1 - .../demo/dto/develop/ItemDefResponse.java | 25 ------------------- .../demo/dto/develop/ItemEffectResponse.java | 10 -------- 3 files changed, 36 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index f12e8a29..c45db1c7 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.develop.ItemDefResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.service.ItemDefService; diff --git a/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java b/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java deleted file mode 100644 index ca07ea68..00000000 --- a/src/main/java/com/scriptopia/demo/dto/develop/ItemDefResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.scriptopia.demo.dto.develop; - - -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - -@Data -public class ItemDefResponse { - private Long id; - private String name; - private String description; - private String picSrc; - private String itemType; // enum 대신 String으로 전달 - private String mainStat; // enum 대신 String - private Integer baseStat; - private Integer strength; - private Integer agility; - private Integer intelligence; - private Integer luck; - private Long price; - private LocalDateTime createdAt; - private List effects; -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java b/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java deleted file mode 100644 index d530f75a..00000000 --- a/src/main/java/com/scriptopia/demo/dto/develop/ItemEffectResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.scriptopia.demo.dto.develop; - -import lombok.Data; - -@Data -public class ItemEffectResponse { - private String effectName; - private String effectDescription; - private String grade; // enum 대신 String -} \ No newline at end of file From 2d66da3b38f50e84d9344ff69a806a1223377cf9 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 6 Sep 2025 21:18:24 +0900 Subject: [PATCH 282/527] refactor/ sharedGameService dry --- .../PublicSharedGameDetailResponse.java | 2 +- .../SharedGameFavoriteResponse.java | 2 +- .../demo/repository/GameTagRepository.java | 6 +- .../SharedGameFavoriteRepository.java | 9 ++- .../demo/repository/SharedGameRepository.java | 3 +- .../service/SharedGameFavoriteService.java | 3 +- .../demo/service/SharedGameService.java | 57 ++++++++++++------- 7 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index d5bb16ba..cede952a 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -33,7 +33,7 @@ public TagDto(String tagName) { @Data public static class TopScoreDto { private String nickname; - private Float score; + private Long score; @JsonFormat(shape = JsonFormat.Shape.STRING) private LocalDateTime createdAt; } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java index ed40383f..ff091570 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java @@ -11,5 +11,5 @@ public class SharedGameFavoriteResponse { private Long totalPlayCount; private String title; private String[] tags; - private Float topScore; + private Long topScore; } diff --git a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java index 6910cde9..863b2da1 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameTagRepository.java @@ -16,16 +16,12 @@ public interface GameTagRepository extends JpaRepository { "where gt.sharedGame.id = :sharedGameId") List findTagNamesBySharedGameId(@Param("sharedGameId") Long sharedGameId); - @Query("select new com.scriptopia.demo.dto.TagDef.TagDefCreateRequest(gt.tagDef.tagName) " + - "from GameTag gt where gt.sharedGame.id = :sharedGameId") - List findTagsBySharedGameId(@Param("sharedGameId") Long sharedGameId); - @Query(""" select new com.scriptopia.demo.dto.sharedgame.TagDto(td.id, td.tagName) from GameTag gt join gt.tagDef td where gt.sharedGame.id = :sharedGameId order by td.tagName asc -""") + """) List findTagDtosBySharedGameId(@Param("sharedGameId") Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java index 2b7af374..49585b0d 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameFavoriteRepository.java @@ -3,6 +3,8 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -13,6 +15,9 @@ public interface SharedGameFavoriteRepository extends JpaRepository 0 then true else false end from SharedGameFavorite sgf + join sgf.user u join sgf.sharedGame sg where u.id = :userId and sg.id = :sharedGameId + """) + boolean existsLikeSharedGame(@Param("userId") Long userId, @Param("sharedGameId") Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index ab82c0ef..543a8f14 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -11,7 +11,8 @@ import java.util.List; public interface SharedGameRepository extends JpaRepository { - List findAllByUserId(Long userId); + @Query("select sg from SharedGame sg where sg.user.id = :userId") + List findAllByUserid(@Param("userId") Long userId); // 기본(전체) @Query(""" diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java index fccacef1..61d092c7 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java @@ -48,7 +48,6 @@ public ResponseEntity saveFavorite(Long userId, Long sharedGameId) { // 태그 이름들 var tagNames = gameTagRepository.findTagNamesBySharedGameId(sharedGameId); - // DTO 구성 var dto = new SharedGameFavoriteResponse(); dto.setSharedGameId(sharedGameId); dto.setThumbnailUrl(game.getThumbnailUrl()); @@ -57,7 +56,7 @@ public ResponseEntity saveFavorite(Long userId, Long sharedGameId) { dto.setTotalPlayCount(playCount); dto.setTitle(game.getTitle()); dto.setTags(tagNames.isEmpty() ? null : tagNames.toArray(new String[0])); - dto.setTopScore(maxScore == null ? null : maxScore.floatValue()); + dto.setTopScore(maxScore); return ResponseEntity.ok(dto); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index a4eb7f43..3e8866d5 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; @Service @@ -45,30 +46,34 @@ public ResponseEntity getMySharedGames(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - List games = sharedGameRepository.findAllByUserId(user.getId()); + List games = sharedGameRepository.findAllByUserid(user.getId()); - List responses = games.stream().map(game -> { + List dtos = new ArrayList<>(); + + for(SharedGame game : games) { MySharedGameResponse dto = new MySharedGameResponse(); dto.setThumbnailUrl(game.getThumbnailUrl()); dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); dto.setTitle(game.getTitle()); dto.setWorldView(game.getWorldView()); - dto.setBackgroundStory(game.getBackgroundStory()); dto.setSharedAt(game.getSharedAt()); + dto.setBackgroundStory(game.getBackgroundStory()); - boolean liked = sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(user.getId(), game.getId()); + boolean liked = sharedGameFavoriteRepository.existsLikeSharedGame(user.getId(), game.getId()); dto.setRecommand(liked); - List names = gameTagRepository.findTagNamesBySharedGameId(game.getId()); - dto.setTags( - names.stream() - .map(MySharedGameResponse.TagDto::new) - .toList() - ); - return dto; - }).toList(); + List tagdto = gameTagRepository.findTagNamesBySharedGameId(game.getId()); + List tags = new ArrayList<>(); - return ResponseEntity.ok(responses); + for(String tagName : tagdto) { + tags.add(new MySharedGameResponse.TagDto(tagName)); + } + + dto.setTags(tags); + dtos.add(dto); + } + + return ResponseEntity.ok(dtos); } @Transactional @@ -104,14 +109,24 @@ public ResponseEntity getDetailedSharedGame(Long sharedId) { dto.setBackgroundStory(game.getBackgroundStory()); dto.setSharedAt(game.getSharedAt()); - dto.setTags(tagName.stream().map(PublicSharedGameDetailResponse.TagDto::new).toList()); - dto.setTopScores(score.stream().map(s ->{ - PublicSharedGameDetailResponse.TopScoreDto topScoreDto = new PublicSharedGameDetailResponse.TopScoreDto(); - topScoreDto.setNickname(s.getUser().getNickname()); - topScoreDto.setScore(s.getScore().floatValue()); - topScoreDto.setCreatedAt(s.getCreatedAt()); - return topScoreDto; - }).toList() ); + List tagarray = new ArrayList<>(); + List topscorearray = new ArrayList<>(); + + for(var tagNames : tagName) { + tagarray.add(new PublicSharedGameDetailResponse.TagDto(tagNames)); + } + + dto.setTags(tagarray); + + for(var topScoreInfo : score) { + PublicSharedGameDetailResponse.TopScoreDto topscore = new PublicSharedGameDetailResponse.TopScoreDto(); + topscore.setNickname(topScoreInfo.getUser().getNickname()); + topscore.setScore(topScoreInfo.getScore()); + topscore.setCreatedAt(topScoreInfo.getCreatedAt()); + topscorearray.add(topscore); + } + + dto.setTopScores(topscorearray); return ResponseEntity.ok(dto); } From 41b55f1ef5157612e1cdb4019b2757dfa0a3830b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 21:21:00 +0900 Subject: [PATCH 283/527] refactor: delete blank line --- src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index 717800ea..253a5d8f 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -11,9 +11,6 @@ @RequiredArgsConstructor public class DataLoaderConfig { - private final ItemGradeDefRepository itemGradeDefRepository; - private final EffectGradeDefRepository effectGradeDefRepository; - @Bean public ApplicationRunner dataLoader() { return args -> { From ede14a69a16574aa58e467e288848fa7fbf82bd8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 21:21:59 +0900 Subject: [PATCH 284/527] refactor: change random price method --- .../demo/repository/EffectGradeDefRepository.java | 3 +-- .../com/scriptopia/demo/service/ItemDefService.java | 10 +--------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index c926503f..0f95cb70 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -14,7 +14,6 @@ public interface EffectGradeDefRepository extends JpaRepository findByGrade(Grade grade); @Query("SELECT egd.price FROM EffectGradeDef egd WHERE egd.grade = :grade") - Long findPriceByGrade(@Param("grade") Grade grade); + Optional findPriceByGrade(@Param("grade") Grade grade); - Long findPriceByGrade(EffectProbability effectGrade); } diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 9148f51a..df774294 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -4,8 +4,6 @@ import com.scriptopia.demo.domain.*; import com.scriptopia.demo.domain.mongo.ItemDefMongo; import com.scriptopia.demo.domain.mongo.ItemEffectMongo; -import com.scriptopia.demo.dto.develop.ItemDefResponse; -import com.scriptopia.demo.dto.develop.ItemEffectResponse; import com.scriptopia.demo.dto.items.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; @@ -21,7 +19,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -57,7 +54,7 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { EffectProbability effectGrade = EffectProbability.getRandomEffectGradeByWeaponGrade(itemGrade); if (effectGrade != null) { effectGrades.add(effectGrade); - effectGradesList.add(effectGradeDefRepository.findPriceByGrade(effectGrade)); + effectGradesList.add(effectGradeDefRepository.findPriceByGrade(EffectAdapter.toGrade(effectGrade)).get()); } } @@ -101,8 +98,6 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { .bodyToMono(ItemFastApiResponse.class) .block(); // 블로킹 호출 (간단 테스트용) - System.out.println(response ); - List mongoEffects = new ArrayList<>(); List apiEffects = response.getItemEffect(); @@ -118,9 +113,6 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { .build()); } - System.out.println("mongoEffects = " + mongoEffects ); - - ItemDefMongo itemDefMongo = ItemDefMongo.builder() .itemPicSrc("test link") .name(response.getItemName()) From 58dd3af6658509936f8124fedd1c00cd2d164ed9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 6 Sep 2025 21:22:53 +0900 Subject: [PATCH 285/527] refactor: change random price method --- .../demo/service/GameSessionService.java | 2 ++ .../com/scriptopia/demo/utils/InitGameData.java | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index ee4fe9bd..8c85ea37 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -130,6 +130,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { effectGradeDefRepository ); + System.out.println("------------- 여기도 옸습니다22 " + initGameData); + // GameSession Data GameSessionMongo mongoSession = new GameSessionMongo(); mongoSession.setUserId(userId); diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index a54b3ead..1992105d 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.utils; +import com.scriptopia.demo.adapter.EffectAdapter; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; @@ -43,6 +44,9 @@ public class InitGameData { public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRepo, EffectGradeDefRepository effectRepo) { + System.out.println("--------------------------- 잘 왔습니다."); + + this.itemGradeDefRepository = itemRepo; this.effectGradeDefRepository = effectRepo; @@ -56,6 +60,9 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep int mainStat = secureRandom.nextInt(3); int subStat = secureRandom.nextInt(3) - 2; + System.out.println("--------------------------- 잘 왔습니다. 22222"); + + this.playerStr = (playerStat.equals(Stat.STRENGTH)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; this.playerAgi = (playerStat.equals(Stat.AGILITY)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; this.playerInt = (playerStat.equals(Stat.INTELLIGENCE)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; @@ -68,6 +75,10 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.baseStat = (int) Math.floor(Grade.COMMON.getAttackPower() * (1 + attackRate / 100.0)); + + System.out.println("--------------------------- 잘 왔습니다. 333333333333333"); + + // 배열 생성 (0: STR, 1: AGI, 2: INT, 3: LUCK) int[] stats = GameBalanceUtil.getRandomItemStatsByGrade(grade); this.itemStr = stats[0]; @@ -75,11 +86,14 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.itemInt = stats[2]; this.itemLuk = stats[3]; + System.out.println("--------------------------- 잘 왔습니다. 444444"); List itemEffectList = new ArrayList<>(); - itemEffectList.add(effectGradeDefRepository.findPriceByGrade(EffectProbability.COMMON)); + itemEffectList.add(effectGradeDefRepository.findPriceByGrade(Grade.COMMON).get()); Long gradePrice = itemGradeDefRepository.findPriceByGrade(grade); + + this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice , itemEffectList); } From c239cefe1eba9fc212f79804a0e249406ac1fd0d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 7 Sep 2025 14:05:16 +0900 Subject: [PATCH 286/527] entity modify UUID column add --- .../demo/controller/PublicSharedGameController.java | 2 +- .../java/com/scriptopia/demo/domain/SharedGame.java | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java index 90b55908..341fbc82 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -26,7 +26,7 @@ public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); } - @GetMapping("/check") + @GetMapping public ResponseEntity> getPublicSharedGames(Authentication authentication, @RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "20") int size, diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index c272f0e0..939aa010 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -7,6 +7,7 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.UUID; @Entity @@ -22,6 +23,9 @@ public class SharedGame { @ManyToOne(fetch = FetchType.LAZY) private User user; + @Column(nullable = false, unique = true, columnDefinition = "BINARY(16)") + private UUID uuid; + private String thumbnailUrl; private Long recommend = 0L; private Long totalPlayed = 0L; @@ -36,6 +40,13 @@ public class SharedGame { private String backgroundStory; private LocalDateTime sharedAt; + @PrePersist + public void generateUuid() { + if(uuid == null) { + uuid = UUID.randomUUID(); + } + } + public static SharedGame from(User user, History h) { SharedGame game = new SharedGame(); game.user = user; From 8444531089d8692fa1815bd5772373efdbc76475 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 7 Sep 2025 15:41:04 +0900 Subject: [PATCH 287/527] Repository modify SharedGame --- .../com/scriptopia/demo/controller/GameSessionController.java | 4 ++-- src/main/java/com/scriptopia/demo/domain/SharedGame.java | 2 +- .../scriptopia/demo/dto/sharedgame/MySharedGameResponse.java | 1 + .../com/scriptopia/demo/repository/SharedGameRepository.java | 4 ++++ .../java/com/scriptopia/demo/service/SharedGameService.java | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 0126ada6..50cfec3c 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -52,10 +52,10 @@ public ResponseEntity startNewGame( * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ @PostMapping("/{gameId}/history") - public ResponseEntity addHistory(@PathVariable String sid, Authentication authentication) { + public ResponseEntity addHistory(@PathVariable String gameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - return historyService.createHistory(userId, sid); + return historyService.createHistory(userId, gameId); } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index 939aa010..e077b5ee 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -23,7 +23,7 @@ public class SharedGame { @ManyToOne(fetch = FetchType.LAZY) private User user; - @Column(nullable = false, unique = true, columnDefinition = "BINARY(16)") + @Column(nullable = false, unique = true) private UUID uuid; private String thumbnailUrl; diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java index b48dc454..a386eaa8 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java @@ -7,6 +7,7 @@ @Data public class MySharedGameResponse { + private String uuid; private String thumbnailUrl; private boolean recommand; private Long totalPlayed; diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index 543a8f14..650d1eb1 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -9,11 +9,15 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface SharedGameRepository extends JpaRepository { @Query("select sg from SharedGame sg where sg.user.id = :userId") List findAllByUserid(@Param("userId") Long userId); + @Query("select sg.id from SharedGame sg where sg.uuid = :uuid") + Long findByUuid(@Param("uuid") String uuid); + // 기본(전체) @Query(""" select g from SharedGame g diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 3e8866d5..8c3ca5f3 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -52,6 +52,7 @@ public ResponseEntity getMySharedGames(Long userId) { for(SharedGame game : games) { MySharedGameResponse dto = new MySharedGameResponse(); + dto.setUuid(game.getUuid().toString()); dto.setThumbnailUrl(game.getThumbnailUrl()); dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); dto.setTitle(game.getTitle()); From 6d0490fd8e94bca936583d7112aee678963ed8bd Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 15:44:19 +0900 Subject: [PATCH 288/527] refactor: modify endpoint of auth-controller delete role in uri --- .../demo/config/SecurityConfig.java | 5 ++--- .../demo/controller/AuthController.java | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index ee1c8e9f..b2a48b1a 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -50,9 +50,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { })) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/public/**").permitAll() - .requestMatchers("/user/**").hasAnyAuthority("USER", "ADMIN") - .requestMatchers("/admin/**").hasAnyAuthority("ADMIN") + .requestMatchers("/auth/password, /auth/token").authenticated() + .requestMatchers("/auth/**","").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 57783c2d..db01cbf1 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -19,6 +19,7 @@ import java.util.List; @RestController +@RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final LocalAccountService localAccountService; @@ -32,7 +33,7 @@ public class AuthController { - @PostMapping("/user/auth/logout") + @PostMapping("/logout") public ResponseEntity logout( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, HttpServletResponse response @@ -45,7 +46,7 @@ public ResponseEntity logout( } - @PostMapping("/public/auth/login") + @PostMapping("/login") public ResponseEntity login( @RequestBody @Valid LoginRequest req, HttpServletRequest request, @@ -55,7 +56,7 @@ public ResponseEntity login( return ResponseEntity.ok(localAccountService.login(req, request, response)); } - @PostMapping("/public/auth/register") + @PostMapping("/register") public ResponseEntity register( @RequestBody @Valid RegisterRequest request ) { @@ -63,7 +64,7 @@ public ResponseEntity register( return ResponseEntity.ok("회원가입에 성공했습니다."); } - @PostMapping("/public/auth/email/verify") + @PostMapping("/email/verify") public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { localAccountService.verifyEmail(request); @@ -72,13 +73,13 @@ public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest requ } - @PostMapping("/public/auth/email/code/send") + @PostMapping("/email/code/send") public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { localAccountService.sendVerificationCode(request.getEmail()); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } - @PostMapping("/public/auth/email/code/verify") + @PostMapping("/email/code/verify") public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { localAccountService.verifyCode(request.getEmail(), request.getCode()); return ResponseEntity.ok("이메일 인증이 완료되었습니다."); @@ -86,7 +87,7 @@ public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest r } - @PostMapping("/public/auth/password/reset-link/send") + @PostMapping("/password/reset-link/send") public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ localAccountService.sendResetPasswordMail(request.getEmail()); @@ -95,7 +96,7 @@ public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest reque } - @PatchMapping("public/auth/password/reset") + @PatchMapping("/password/reset") public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { localAccountService.resetPassword(request.getToken(), request.getNewPassword()); @@ -103,7 +104,7 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest } - @PatchMapping("/user/auth/password/change") + @PatchMapping("/password/change") public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, Authentication authentication) { @@ -116,7 +117,7 @@ public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordR // 쿠키 기반 리프레시 - @PostMapping("/user/auth/token/refresh") + @PostMapping("/token/refresh") public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, @RequestParam(required = false) String deviceId From 9527597e2d3374b3947dce9d0f4b45088c09adea Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 15:58:02 +0900 Subject: [PATCH 289/527] refactor: change itemGrade and itemEffectGrade method repository --- .../demo/adapter/EffectAdapter.java | 19 ------- .../demo/config/DataLoaderConfig.java | 49 +++++++++++++++++-- .../demo/domain/EffectGradeDef.java | 2 +- .../demo/domain/mongo/ItemEffectMongo.java | 6 +-- .../demo/dto/auction/AuctionItemResponse.java | 3 +- .../demo/repository/AuctionRepository.java | 5 +- .../repository/EffectGradeDefRepository.java | 7 ++- .../demo/service/AuctionService.java | 2 +- .../demo/service/GameSessionService.java | 2 +- .../demo/service/ItemDefService.java | 19 +++++-- .../scriptopia/demo/utils/InitGameData.java | 5 +- 11 files changed, 74 insertions(+), 45 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java diff --git a/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java b/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java deleted file mode 100644 index fb203552..00000000 --- a/src/main/java/com/scriptopia/demo/adapter/EffectAdapter.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.scriptopia.demo.adapter; - -import com.scriptopia.demo.domain.EffectProbability; -import com.scriptopia.demo.domain.Grade; - -public class EffectAdapter { - - public static Grade toGrade(EffectProbability effectProb) { - if (effectProb == null) return null; - - return switch (effectProb) { - case COMMON -> Grade.COMMON; - case UNCOMMON -> Grade.UNCOMMON; - case RARE -> Grade.RARE; - case EPIC -> Grade.EPIC; - case LEGENDARY -> Grade.LEGENDARY; - }; - } -} diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index 253a5d8f..fd7151a3 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -1,20 +1,63 @@ package com.scriptopia.demo.config; -import com.scriptopia.demo.repository.EffectGradeDefRepository; -import com.scriptopia.demo.repository.ItemGradeDefRepository; +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.repository.*; import lombok.RequiredArgsConstructor; import org.springframework.boot.ApplicationRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Map; + @Configuration @RequiredArgsConstructor public class DataLoaderConfig { + private final EffectGradeDefRepository effectGradeDefRepository; + private final ItemGradeDefRepository itemGradeDefRepository; // 추가 + @Bean public ApplicationRunner dataLoader() { return args -> { + // EffectGradeDef 초기화 + Map effectPriceMap = Map.of( + EffectProbability.COMMON, 10L, + EffectProbability.UNCOMMON, 30L, + EffectProbability.RARE, 50L, + EffectProbability.EPIC, 80L, + EffectProbability.LEGENDARY, 100L + ); + + for (EffectProbability prob : EffectProbability.values()) { + if (prob == null) continue; + if (effectGradeDefRepository.findByEffectProbability(prob).isEmpty()) { + EffectGradeDef def = new EffectGradeDef(); + def.setEffectProbability(prob); + def.setPrice(effectPriceMap.get(prob)); + def.setWeight(1.0); + effectGradeDefRepository.save(def); + } + } + + // ItemGradeDef 초기화 + Map itemGradePriceMap = Map.of( + Grade.COMMON, 10L, + Grade.UNCOMMON, 30L, + Grade.RARE, 50L, + Grade.EPIC, 80L, + Grade.LEGENDARY, 100L + ); + for (Grade grade : Grade.values()) { + if (grade == null) continue; + if (itemGradeDefRepository.findByGrade(grade).isEmpty()) { + ItemGradeDef def = new ItemGradeDef(); + def.setGrade(grade); + def.setPrice(itemGradePriceMap.get(grade)); + def.setWeight(1.0); + itemGradeDefRepository.save(def); + } + } }; } -} \ No newline at end of file +} diff --git a/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java b/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java index 91a1b067..0e5d2fe0 100644 --- a/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java +++ b/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java @@ -18,5 +18,5 @@ public class EffectGradeDef { private Double weight; @Enumerated(EnumType.STRING) - private Grade grade; + private EffectProbability effectProbability; } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java index d691c9bf..5506823f 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ItemEffectMongo.java @@ -1,6 +1,6 @@ package com.scriptopia.demo.domain.mongo; -import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.EffectProbability; import lombok.*; @Data @@ -10,5 +10,5 @@ public class ItemEffectMongo { private String itemEffectName; private String itemEffectDescription; - private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY -} + private EffectProbability effectProbability; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java index 44c051b2..e3abffd6 100644 --- a/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/auction/AuctionItemResponse.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.auction; +import com.scriptopia.demo.domain.EffectProbability; import com.scriptopia.demo.domain.TradeStatus; import lombok.AllArgsConstructor; import lombok.Data; @@ -55,6 +56,6 @@ public static class ItemDto { public static class ItemEffectDto { private String effectName; private String effectDescription; - private String grade; + private EffectProbability effectProbability; } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index 922825ae..d9a07611 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -45,7 +45,7 @@ WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) OR EXISTS ( SELECT 1 FROM ItemEffect ie2 WHERE ie2.itemDef = id - AND ie2.effectGradeDef.grade IN :effectGrades + AND ie2.effectGradeDef.effectProbability IN :effectGrades ) ) """) @@ -65,7 +65,4 @@ Page findByUserItem_User_IdAndUserItem_TradeStatus( TradeStatus tradeStatus, Pageable pageable ); - - - } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java index 0f95cb70..361a3a3c 100644 --- a/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/EffectGradeDefRepository.java @@ -2,7 +2,6 @@ import com.scriptopia.demo.domain.EffectGradeDef; import com.scriptopia.demo.domain.EffectProbability; -import com.scriptopia.demo.domain.Grade; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,9 +10,9 @@ import java.util.Optional; public interface EffectGradeDefRepository extends JpaRepository { - Optional findByGrade(Grade grade); + Optional findByEffectProbability(EffectProbability effectProbability); - @Query("SELECT egd.price FROM EffectGradeDef egd WHERE egd.grade = :grade") - Optional findPriceByGrade(@Param("grade") Grade grade); + @Query("SELECT egd.price FROM EffectGradeDef egd WHERE egd.effectProbability = :effectProbability") + Optional findPriceByEffectProbability(@Param("effectProbability") EffectProbability effectProbability); } diff --git a/src/main/java/com/scriptopia/demo/service/AuctionService.java b/src/main/java/com/scriptopia/demo/service/AuctionService.java index 7117ba32..b01d7de0 100644 --- a/src/main/java/com/scriptopia/demo/service/AuctionService.java +++ b/src/main/java/com/scriptopia/demo/service/AuctionService.java @@ -142,7 +142,7 @@ public TradeResponse getTrades(TradeFilterRequest request) { AuctionItemResponse.ItemEffectDto effDto = new AuctionItemResponse.ItemEffectDto(); effDto.setEffectName(e.getEffectName()); effDto.setEffectDescription(e.getEffectDescription()); - effDto.setGrade(e.getEffectGradeDef().getGrade().name()); + effDto.setEffectProbability(e.getEffectGradeDef().getEffectProbability()); return effDto; }) .toList(); diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 8c85ea37..733bf62c 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -173,7 +173,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { ItemEffectMongo.builder() .itemEffectName(itemEffect.getItemEffectName()) .itemEffectDescription(itemEffect.getItemEffectDescription()) - .grade(Grade.COMMON) + .effectProbability(EffectProbability.COMMON) .build() ); diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index df774294..62f0e923 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.adapter.EffectAdapter; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.domain.mongo.ItemDefMongo; import com.scriptopia.demo.domain.mongo.ItemEffectMongo; @@ -19,6 +18,8 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -53,11 +54,16 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { for (int i = 0; i < 3; i++) { EffectProbability effectGrade = EffectProbability.getRandomEffectGradeByWeaponGrade(itemGrade); if (effectGrade != null) { + Long effectPrice = effectGradeDefRepository.findPriceByEffectProbability(effectGrade) + .orElseThrow(() -> new IllegalStateException("EffectGradeDef not found: " + effectGrade)); + + effectGradesList.add(effectPrice); effectGrades.add(effectGrade); - effectGradesList.add(effectGradeDefRepository.findPriceByGrade(EffectAdapter.toGrade(effectGrade)).get()); + // effectGradesList.add(effectGradeDefRepository.findPriceByEffectProbability(effectGrade).get()); } } + System.out.println(effectGrades); Long gradeGradePrice = itemGradeDefRepository.findPriceByGrade(itemGrade); Long itemPrice = GameBalanceUtil.getItemPriceByGrade(gradeGradePrice, effectGradesList); @@ -99,6 +105,8 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { .block(); // 블로킹 호출 (간단 테스트용) + System.out.println("Fast Api = " + response); + List mongoEffects = new ArrayList<>(); List apiEffects = response.getItemEffect(); @@ -107,7 +115,7 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { EffectProbability effectGrade = i < effectGrades.size() ? effectGrades.get(i) : null; mongoEffects.add(ItemEffectMongo.builder() - .grade(effectGrade != null ? EffectAdapter.toGrade(effectGrade) : Grade.COMMON) + .effectProbability(effectGrade != null ? (effectGrade) : EffectProbability.COMMON) .itemEffectName(apiEffect.getItemEffectName()) .itemEffectDescription(apiEffect.getItemEffectDescription()) .build()); @@ -152,11 +160,14 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { effect.setItemDef(itemDefRdb); effect.setEffectName(effectMongo.getItemEffectName()); effect.setEffectDescription(effectMongo.getItemEffectDescription()); - effect.setEffectGradeDef(effectGradeDefRepository.findByGrade(effectMongo.getGrade()).get()); + effect.setEffectGradeDef(effectGradeDefRepository.findByEffectProbability(effectMongo.getEffectProbability()).get()); rdbEffects.add(effect); } itemDefRdb.setItemEffects(rdbEffects); + + System.out.println(itemDefRdb); + itemDefRepository.save(itemDefRdb); return response; diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 1992105d..84db0e8b 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.utils; -import com.scriptopia.demo.adapter.EffectAdapter; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; @@ -89,11 +88,9 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep System.out.println("--------------------------- 잘 왔습니다. 444444"); List itemEffectList = new ArrayList<>(); - itemEffectList.add(effectGradeDefRepository.findPriceByGrade(Grade.COMMON).get()); + itemEffectList.add(effectGradeDefRepository.findPriceByEffectProbability(EffectProbability.COMMON).get()); Long gradePrice = itemGradeDefRepository.findPriceByGrade(grade); - - this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice , itemEffectList); } From b10d827a86a7ecad6f97807da0ba2aeeca166635 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 16:26:37 +0900 Subject: [PATCH 290/527] feat: create user choice request to game progress --- .../demo/dto/gamesession/GameChoiceRequest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameChoiceRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameChoiceRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameChoiceRequest.java new file mode 100644 index 00000000..c2bf6fb1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameChoiceRequest.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class GameChoiceRequest { + private Integer choiceIndex; + private String customAction; +} From 4b2a7945e0430531cb60cc887d1995eaf974bfec Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 16:32:40 +0900 Subject: [PATCH 291/527] wip: notiong to change --- .../com/scriptopia/demo/controller/GameSessionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 50cfec3c..3c9e80ac 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.dto.gamesession.GameChoiceRequest; import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; import com.scriptopia.demo.service.GameSessionService; @@ -46,7 +47,6 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } - /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 From d3060d1980b042d35372a72a9c6794f0009f037c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 16:42:06 +0900 Subject: [PATCH 292/527] refactor: change method name to _Id --- .../scriptopia/demo/repository/GameSessionRepository.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index de7237f7..aaf97341 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -9,13 +9,11 @@ import java.util.Optional; public interface GameSessionRepository extends JpaRepository { - Optional findByUser_IdAndMongoId(Long userId, String mongoId); - - boolean existsByUser_Id(Long userId); + Optional findByUserIdAndMongoId(Long userId, String mongoId); @Query("select g from GameSession g join g.user u where u.id = :userId") Optional findByMongoId(@Param("userId") Long userId); @Query("select case when (Count(g) > 0) then true else false end from GameSession g join g.user u where u.id = :userId") - boolean existsByUserId(@Param("userid") Long userId); + boolean existsByUserId(@Param("userId") Long userId); } From 5c4061bfc3277c610a52898b14532484ab05ec07 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:01:45 +0900 Subject: [PATCH 293/527] refactor: add loction gameinit --- .../demo/service/GameSessionService.java | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 6971a3d4..2e61d3c3 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.service; +import com.mongodb.client.MongoClient; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; import com.scriptopia.demo.utils.InitGameData; @@ -33,6 +34,7 @@ public class GameSessionService { private final UserRepository userRepository; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; + private final MongoClient mongo; public boolean duplcatedGameSession(Long userId) { User user = userRepository.findById(userId) @@ -77,7 +79,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - GameSession gameSession = gameSessionRepository.findByUser_IdAndMongoId(user.getId(), sessionId) + GameSession gameSession = gameSessionRepository.findByUserIdAndMongoId(user.getId(), sessionId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); gameSessionRepository.delete(gameSession); @@ -89,7 +91,7 @@ public ResponseEntity deleteGameSession(Long userId, String sessionId) { public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 1. 진행중인 게임 체크 - if (gameSessionRepository.existsByUser_Id(userId)) { + if (gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_400_GAME_ALREADY_IN_PROGRESS); } @@ -142,7 +144,6 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { effectGradeDefRepository ); - System.out.println("------------- 여기도 옸습니다22 " + initGameData); // GameSession Data GameSessionMongo mongoSession = new GameSessionMongo(); @@ -151,6 +152,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setStartedAt(LocalDateTime.now()); mongoSession.setUpdatedAt(LocalDateTime.now()); mongoSession.setBackground(request.getBackground()); + mongoSession.setLocation(externalGame.getLocation()); mongoSession.setProgress(0); mongoSession.setStage(initGameData.getStages()); @@ -276,4 +278,26 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { } + @Transactional + public void createChoice(Long userId, String gameId){ + + if (gameSessionRepository.existsByUserId(userId)){ + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + if (gameSessionRepository.existsByMongoId(gameId)){ + throw new CustomException(ErrorCode.E_404_Game_Session_NOT_FOUND); + } + + + /** + * 1. mongoDB에서 정보를 가져오기 gameId를 통해 정보를 가져옴 + * 2. background + * + */ + + + + + } } From 7ef58249b1060619a425bfb55a44c16ea05da660 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:02:24 +0900 Subject: [PATCH 294/527] refactor: delete test code --- src/main/java/com/scriptopia/demo/utils/InitGameData.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/InitGameData.java b/src/main/java/com/scriptopia/demo/utils/InitGameData.java index 84db0e8b..6c1c8596 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitGameData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitGameData.java @@ -43,7 +43,6 @@ public class InitGameData { public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRepo, EffectGradeDefRepository effectRepo) { - System.out.println("--------------------------- 잘 왔습니다."); this.itemGradeDefRepository = itemRepo; @@ -59,7 +58,6 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep int mainStat = secureRandom.nextInt(3); int subStat = secureRandom.nextInt(3) - 2; - System.out.println("--------------------------- 잘 왔습니다. 22222"); this.playerStr = (playerStat.equals(Stat.STRENGTH)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; @@ -75,7 +73,6 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep - System.out.println("--------------------------- 잘 왔습니다. 333333333333333"); // 배열 생성 (0: STR, 1: AGI, 2: INT, 3: LUCK) @@ -85,7 +82,6 @@ public InitGameData(Stat playerStat, Grade grade, ItemGradeDefRepository itemRep this.itemInt = stats[2]; this.itemLuk = stats[3]; - System.out.println("--------------------------- 잘 왔습니다. 444444"); List itemEffectList = new ArrayList<>(); itemEffectList.add(effectGradeDefRepository.findPriceByEffectProbability(EffectProbability.COMMON).get()); From 52fd928b8e3b93e6cd0593cd2e60ec9467baf480 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:03:44 +0900 Subject: [PATCH 295/527] feat: create existsByMongoId method --- .../com/scriptopia/demo/repository/GameSessionRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java index aaf97341..34253bcf 100644 --- a/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/GameSessionRepository.java @@ -16,4 +16,7 @@ public interface GameSessionRepository extends JpaRepository @Query("select case when (Count(g) > 0) then true else false end from GameSession g join g.user u where u.id = :userId") boolean existsByUserId(@Param("userId") Long userId); + + boolean existsByMongoId(String mongoId); + } From 6bd428fd2674ba2bb66539064b69d26f5da2ed1d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:04:05 +0900 Subject: [PATCH 296/527] feat: E_404_Game_Session_NOT_FOUND error --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index baf3cc7b..c4ac2bb2 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -39,7 +39,6 @@ public enum ErrorCode { E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), - //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), @@ -67,6 +66,9 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), + E_404_Game_Session_NOT_FOUND("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), + + //409 Conflict From 2ec96694c0333e07e9e18f3c8f46aba9c71fcb00 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 7 Sep 2025 17:27:27 +0900 Subject: [PATCH 297/527] MyPageController UUID modify and service modify --- .../demo/controller/MyPageController.java | 39 ++++++++++++++----- .../com/scriptopia/demo/domain/History.java | 10 +++++ .../dto/sharedgame/MySharedGameResponse.java | 3 +- .../demo/repository/HistoryRepository.java | 7 ++++ .../demo/repository/SharedGameRepository.java | 5 ++- .../demo/service/SharedGameService.java | 13 ++++--- 6 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index e9da0759..65865ac5 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -18,7 +19,10 @@ public class MyPageController { private final SharedGameService sharedGameService; private final GameSessionService gameSessionService; - @GetMapping("/user/my-page/history") + /* + 계정관리 : 내 히스토리 조회 -> 무한스크롤 + */ + @GetMapping("/my-page/history") public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "10") int size, Authentication authentication) { @@ -27,37 +31,52 @@ public ResponseEntity> getHistory(@RequestParam(requir return historyService.fetchMyHisotry(userId, lastId, size); } - @GetMapping("/user/my-page/games/shared") + /* + 계정관리 : 내가 공유한 게임 조회 + */ + @GetMapping("/my-page/games/shared") public ResponseEntity getMySharedGames(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return sharedGameService.getMySharedGames(userId); } - @PostMapping("/user/my-page/share/{hid}") - public ResponseEntity share(Authentication authentication, @PathVariable Long hid) { + /* + 계정관리 : 내 히스토리 공유하기 + */ + @PostMapping("/my-page/share/{uuid}") + public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { Long userId = Long.valueOf(authentication.getName()); - return sharedGameService.saveSharedGame(userId, hid); + return sharedGameService.saveSharedGame(userId, uuid); } - @DeleteMapping("/user/my-page/share/{gameid}") - public ResponseEntity delete(Authentication authentication, @PathVariable Long gameid) { + /* + 계정관리 : 내가 공유한 게임 삭제 + */ + @DeleteMapping("/my-page/share/{uuid}") + public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { Long userId = Long.valueOf(authentication.getName()); - sharedGameService.deletesharedGame(userId, gameid); + sharedGameService.deletesharedGame(userId, uuid); return ResponseEntity.ok("게임이 삭제되었습니다."); } - @GetMapping("/user/my-page/game") + /* + 계정관리 : 게임 세션(이어하기) 조회 + */ + @GetMapping("/my-page/game") public ResponseEntity loadGameSession(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return gameSessionService.getGameSession(userId); } - @DeleteMapping("/user/my-page/game/{gameId}") + /* + 계정관리 : 게임 세션(이어하기) 삭제 + */ + @DeleteMapping("/my-page/game/{gameId}") public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String gameId) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/domain/History.java b/src/main/java/com/scriptopia/demo/domain/History.java index 5042eabc..e89d9c4d 100644 --- a/src/main/java/com/scriptopia/demo/domain/History.java +++ b/src/main/java/com/scriptopia/demo/domain/History.java @@ -8,6 +8,7 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.UUID; @Entity @Getter @@ -18,6 +19,9 @@ public class History { @Id @GeneratedValue private Long id; + @Column(nullable = false, unique = true) + private UUID uuid; + @ManyToOne(fetch = FetchType.LAZY) private User user; @@ -57,6 +61,12 @@ public class History { private LocalDateTime createdAt; private Boolean isShared; + @PrePersist + public void prePersist() { + if(uuid == null) uuid = UUID.randomUUID(); + if(createdAt == null) createdAt = LocalDateTime.now(); + } + public History(User id, HistoryRequest req) { this.user = id; this.thumbnailUrl = req.getThumbnailUrl(); diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java index a386eaa8..c34d029b 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/MySharedGameResponse.java @@ -4,10 +4,11 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; @Data public class MySharedGameResponse { - private String uuid; + private UUID shared_game_uuid; private String thumbnailUrl; private boolean recommand; private Long totalPlayed; diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java index 1dc854b1..241b66f3 100644 --- a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -1,11 +1,18 @@ package com.scriptopia.demo.repository; import com.scriptopia.demo.domain.History; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; +import java.util.UUID; public interface HistoryRepository extends JpaRepository { + @Query("select h from History h where h.uuid = :uuid") + Optional findByUuid(@Param("uuid") UUID uuid); Page findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long lastId, Pageable pageable); diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index 650d1eb1..b90507c9 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -10,13 +10,14 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; public interface SharedGameRepository extends JpaRepository { @Query("select sg from SharedGame sg where sg.user.id = :userId") List findAllByUserid(@Param("userId") Long userId); - @Query("select sg.id from SharedGame sg where sg.uuid = :uuid") - Long findByUuid(@Param("uuid") String uuid); + @Query("select sg from SharedGame sg where sg.uuid = :uuid") + Optional findByUuid(@Param("uuid") UUID uuid); // 기본(전체) @Query(""" diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 8c3ca5f3..127e2854 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -27,11 +28,11 @@ public class SharedGameService { private final TagDefRepository tagDefRepository; @Transactional - public ResponseEntity saveSharedGame(Long Id, Long historyId) { + public ResponseEntity saveSharedGame(Long Id, UUID uuid) { User user = userRepository.findById(Id) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - History history = historyRepository.findById(historyId) + History history = historyRepository.findByUuid(uuid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); if(!history.getUser().getId().equals(Id)) { @@ -52,7 +53,7 @@ public ResponseEntity getMySharedGames(Long userId) { for(SharedGame game : games) { MySharedGameResponse dto = new MySharedGameResponse(); - dto.setUuid(game.getUuid().toString()); + dto.setShared_game_uuid(game.getUuid()); dto.setThumbnailUrl(game.getThumbnailUrl()); dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); dto.setTitle(game.getTitle()); @@ -78,12 +79,12 @@ public ResponseEntity getMySharedGames(Long userId) { } @Transactional - public void deletesharedGame(Long id, Long sharedId) { + public void deletesharedGame(Long id, UUID uuid) { User user = userRepository.findById(id) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - SharedGame game = sharedGameRepository.findById(sharedId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); + SharedGame game = sharedGameRepository.findByUuid(uuid) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); if(!game.getUser().getId().equals(user.getId())) { // 공유된 게임과 로그인한 사용자가 아닌 경우 throw new CustomException(ErrorCode.E_401_NOT_EQUAL_SHARED_GAME); From 97e069059700687670c20e93a0d6dbf9491a5c4e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:28:27 +0900 Subject: [PATCH 298/527] feat: create CreateGameChoiceRequest dto --- .../gamesession/CreateGameChoiceRequest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java new file mode 100644 index 00000000..c5940deb --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java @@ -0,0 +1,36 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateGameChoiceRequest { + private String worldView; + private String currentStory; + private String location; + private String currentChoice; + private String eventType; + private Integer npcRank; + private PlayerInfo playerInfo; + private List itemInfo; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class PlayerInfo { + private String name; + private String trait; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ItemInfo { + private String name; + private String description; + } +} From d8400d0456e6917b53254c4fb4e7df863eda65a7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:33:57 +0900 Subject: [PATCH 299/527] refactor: change error message --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index c4ac2bb2..36f090f8 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -66,7 +66,7 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), - E_404_Game_Session_NOT_FOUND("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), + E_404_Game_Session_NOT_FOUND("E404009", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), From 85d8546fef21a54efd763a3ca148671ccfe912fe Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:46:12 +0900 Subject: [PATCH 300/527] feat: add RefreshRequest dto for change parameter to request body --- .../scriptopia/demo/dto/token/RefreshRequest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/token/RefreshRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/token/RefreshRequest.java b/src/main/java/com/scriptopia/demo/dto/token/RefreshRequest.java new file mode 100644 index 00000000..0a8ee97a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/token/RefreshRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.token; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RefreshRequest { + private String deviceId; +} From 0700aeb470ff0aac774a00b48bed2c570614f192 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:46:43 +0900 Subject: [PATCH 301/527] refactor: change background data to externalGame.getBackgroundStory --- .../demo/service/GameSessionService.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2e61d3c3..3fff68f2 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -151,7 +151,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setSceneType(SceneType.CHOICE); // 시작은 choice 기본값 mongoSession.setStartedAt(LocalDateTime.now()); mongoSession.setUpdatedAt(LocalDateTime.now()); - mongoSession.setBackground(request.getBackground()); + mongoSession.setBackground(externalGame.getBackgroundStory()); mongoSession.setLocation(externalGame.getLocation()); mongoSession.setProgress(0); mongoSession.setStage(initGameData.getStages()); @@ -279,25 +279,25 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { @Transactional - public void createChoice(Long userId, String gameId){ + public GameSessionMongo createChoice(Long userId){ - if (gameSessionRepository.existsByUserId(userId)){ + if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } - if (gameSessionRepository.existsByMongoId(gameId)){ - throw new CustomException(ErrorCode.E_404_Game_Session_NOT_FOUND); - } + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + String gameId = gameSession.getMongoId(); /** * 1. mongoDB에서 정보를 가져오기 gameId를 통해 정보를 가져옴 - * 2. background - * */ + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); - + return gameSessionMongo; } } From fc0752782031b84cba6c2ec96669a14a22de5101 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:47:13 +0900 Subject: [PATCH 302/527] feat: migrate refesh access token method auth to refresh --- .../demo/controller/refreshController.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/refreshController.java diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java new file mode 100644 index 00000000..027e021f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -0,0 +1,62 @@ +package com.scriptopia.demo.controller; + + +import com.scriptopia.demo.config.JwtProperties; +import com.scriptopia.demo.dto.localaccount.RefreshResponse; +import com.scriptopia.demo.dto.token.RefreshRequest; +import com.scriptopia.demo.service.LocalAccountService; +import com.scriptopia.demo.service.RefreshTokenService; +import com.scriptopia.demo.utils.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.List; + +@RestController +@RequestMapping("/token") +@RequiredArgsConstructor +public class refreshController { + + private final LocalAccountService localAccountService; + private final JwtProvider jwt; + private final RefreshTokenService refreshTokenService; + private final JwtProperties props; + + private static final String RT_COOKIE = "RT"; + private static final boolean COOKIE_SECURE = true; + private static final String COOKIE_SAMESITE = "None"; + + // 쿠키 기반 리프레시 + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(name = RT_COOKIE, required = false) String refreshToken, + @RequestBody RefreshRequest request + ) { + if (refreshToken == null || refreshToken.isBlank()) { + return ResponseEntity.status(401).build(); + } + Long userId = jwt.getUserId(refreshToken); + List roles = localAccountService.getRoles(userId); + + var pair = refreshTokenService.rotate(refreshToken, request.getDeviceId(), roles); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) + .body(new RefreshResponse(pair.accessToken(), props.accessExpSeconds())); + } + + private ResponseCookie refreshCookie(String value) { + return ResponseCookie.from(RT_COOKIE, value) + .httpOnly(true) + .secure(COOKIE_SECURE) + .sameSite(COOKIE_SAMESITE) + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); + } + + +} From f73265d0fbc0842199bf03a0d897eb87842d3285 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:51:55 +0900 Subject: [PATCH 303/527] refactor: delete error code because duplecate --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 36f090f8..5d98eba0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -66,7 +66,6 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), - E_404_Game_Session_NOT_FOUND("E404009", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), From 59ddd4dd2397fd340fa8e745f89a5717783a00f7 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:52:17 +0900 Subject: [PATCH 304/527] feat: update authentication and authorization mechanism delete role uri in end point seperate security filter chain --- .../scriptopia/demo/config/JwtAuthFilter.java | 43 +++++++++++++------ .../demo/config/SecurityConfig.java | 39 +++++++++++++---- .../demo/controller/AuthController.java | 37 +--------------- .../exception/GlobalExceptionHandler.java | 12 +++--- 4 files changed, 67 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 6a8c548a..90df17aa 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -1,5 +1,7 @@ package com.scriptopia.demo.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.dto.exception.ErrorResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; @@ -12,6 +14,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; @@ -22,7 +25,7 @@ import java.security.SignatureException; import java.util.stream.Collectors; -@Component + @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { @@ -32,15 +35,13 @@ public class JwtAuthFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { - String uri = req.getRequestURI(); - if (uri.startsWith("/api/v1/public")) { - chain.doFilter(req, res); - return; - } + + System.out.println(">>> JwtAuthFilter 실행됨, path=" + req.getServletPath()); String authHeader = req.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { - throw new CustomException(ErrorCode.E_400_MISSING_JWT); + setErrorResponse(res, ErrorCode.E_400_MISSING_JWT); + return; } String token = authHeader.substring(7); @@ -56,18 +57,34 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (IllegalArgumentException e) { + setErrorResponse(res,ErrorCode.E_400_MISSING_JWT); + return; } catch (ExpiredJwtException e) { - throw new CustomException(ErrorCode.E_401_EXPIRED_JWT); + setErrorResponse(res,ErrorCode.E_401_EXPIRED_JWT); + return; } catch (MalformedJwtException e) { - throw new CustomException(ErrorCode.E_401_MALFORMED); + setErrorResponse(res,ErrorCode.E_401_MALFORMED); + return; } catch (UnsupportedJwtException e) { - throw new CustomException(ErrorCode.E_401_UNSUPPORTED_JWT); - } catch (IllegalArgumentException e) { - throw new CustomException(ErrorCode.E_400_MISSING_JWT); + setErrorResponse(res,ErrorCode.E_401_UNSUPPORTED_JWT); + return; } catch (JwtException e) { - throw new CustomException(ErrorCode.E_401_INVALID_SIGNATURE); + setErrorResponse(res,ErrorCode.E_401_INVALID_SIGNATURE); + return; } chain.doFilter(req, res); } + + + + + private void setErrorResponse(HttpServletResponse res, ErrorCode code) throws IOException { + res.setStatus(code.getStatus().value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(res.getOutputStream(), new ErrorResponse(code)); + } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index b2a48b1a..522899b4 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -4,10 +4,12 @@ import com.scriptopia.demo.dto.exception.ErrorResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.utils.JwtProvider; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -33,15 +35,34 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - private final JwtAuthFilter jwtAuthFilter; + private final JwtProvider jwtProvider; + +// @Bean +// public JwtAuthFilter jwtAuthFilter(){ +// return new JwtAuthFilter(jwtProvider); +// } + + @Bean + @Order(1) + public SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + + http.securityMatcher("/auth/**") + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } @Bean + @Order(99) // public 체인보다 뒤에서 동작 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .securityMatcher("/**") .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(req -> { var c = new CorsConfiguration(); - c.setAllowedOrigins(List.of("http://localhost:3000")); // 현재는 로컬로 해놓고 나중에 바꿔야 댐 + c.setAllowedOrigins(List.of("http://localhost:3000")); c.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH","OPTIONS")); c.setAllowedHeaders(List.of("Authorization","Content-Type")); c.setAllowCredentials(true); @@ -50,21 +71,21 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { })) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/password, /auth/token").authenticated() - .requestMatchers("/auth/**","").permitAll() - .anyRequest().authenticated() + .anyRequest().authenticated() // 나머지 전부 인증 필요 ) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtAuthFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> { res.setStatus(ErrorCode.E_401.getStatus().value()); res.setContentType(MediaType.APPLICATION_JSON_VALUE); - new ObjectMapper().writeValue(res.getOutputStream(),new ErrorResponse(ErrorCode.E_401)); + new ObjectMapper().writeValue(res.getOutputStream(), + new ErrorResponse(ErrorCode.E_401)); }) .accessDeniedHandler((req, res, e) -> { - res.setStatus(ErrorCode.E_403.getStatus().value()); + res.setStatus(E_403.getStatus().value()); res.setContentType(MediaType.APPLICATION_JSON_VALUE); - new ObjectMapper().writeValue(res.getOutputStream(),new ErrorResponse(ErrorCode.E_403)); + new ObjectMapper().writeValue(res.getOutputStream(), + new ErrorResponse(E_403)); }) ); return http.build(); diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index db01cbf1..e3bbcd1d 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -23,9 +23,7 @@ @RequiredArgsConstructor public class AuthController { private final LocalAccountService localAccountService; - private final JwtProvider jwt; private final RefreshTokenService refreshTokenService; - private final JwtProperties props; private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; @@ -87,7 +85,7 @@ public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest r } - @PostMapping("/password/reset-link/send") + @PostMapping("/password/reset/send") public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ localAccountService.sendResetPasswordMail(request.getEmail()); @@ -116,38 +114,5 @@ public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordR } - // 쿠키 기반 리프레시 - @PostMapping("/token/refresh") - public ResponseEntity refresh( - @CookieValue(name = RT_COOKIE, required = false) String refreshToken, - @RequestParam(required = false) String deviceId - ) { - if (refreshToken == null || refreshToken.isBlank()) { - return ResponseEntity.status(401).build(); - } - Long userId = jwt.getUserId(refreshToken); - List roles = localAccountService.getRoles(userId); - - var pair = refreshTokenService.rotate(refreshToken, deviceId, roles); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) - .body(new RefreshResponse(pair.accessToken(), props.accessExpSeconds())); - } - - - - - - private ResponseCookie refreshCookie(String value) { - return ResponseCookie.from(RT_COOKIE, value) - .httpOnly(true) - .secure(COOKIE_SECURE) - .sameSite(COOKIE_SAMESITE) - .path("/") - .maxAge(Duration.ofDays(14)) - .build(); - } - } diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 127ffbf3..a61e62af 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -65,6 +65,12 @@ public ResponseEntity handleCustomException(final CustomException } + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity handleExpired(ExpiredJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { @@ -72,10 +78,4 @@ public ResponseEntity handleGeneralException(Exception ex) { .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse(ErrorCode.E_500)); } - - @ExceptionHandler(ExpiredJwtException.class) - public ResponseEntity handleExpired(ExpiredJwtException e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); - } } From 82f5a8c675583a97928b79ca4f85d2464804160d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 17:53:59 +0900 Subject: [PATCH 305/527] feat: create E_404_ITEM_NOT_FOUND --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 5d98eba0..5f3bbcd0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -66,7 +66,8 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), - + E_404_ITEM_NOT_FOUND("E404009", "아이템이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + From 75da7f8df9244bd2b79a1c992201e791017d2d01 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 7 Sep 2025 18:01:57 +0900 Subject: [PATCH 306/527] MyPageController UUID infinity scroll --- .../demo/controller/MyPageController.java | 4 ++-- .../com/scriptopia/demo/exception/ErrorCode.java | 1 + .../demo/repository/HistoryRepository.java | 3 +++ .../scriptopia/demo/service/HistoryService.java | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index 65865ac5..72881676 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -23,12 +23,12 @@ public class MyPageController { 계정관리 : 내 히스토리 조회 -> 무한스크롤 */ @GetMapping("/my-page/history") - public ResponseEntity> getHistory(@RequestParam(required = false) Long lastId, + public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, @RequestParam(defaultValue = "10") int size, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - return historyService.fetchMyHisotry(userId, lastId, size); + return historyService.fetchMyHistory(userId, lastId, size); } /* diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index baf3cc7b..ae7ddeb0 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -67,6 +67,7 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), + E_404_PAGE_NOT_FOUND("E404009", "페이지 번호를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), //409 Conflict diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java index 241b66f3..70d4ae14 100644 --- a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -14,6 +14,9 @@ public interface HistoryRepository extends JpaRepository { @Query("select h from History h where h.uuid = :uuid") Optional findByUuid(@Param("uuid") UUID uuid); + @Query("select h.id from History h where h.user.id = : userId and h.uuid = :uuid") + Optional findByUserIdAndUuid(@Param("userId") Long userId, @Param("uuid") UUID uuid); + Page findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long lastId, Pageable pageable); Page findByUserIdOrderByIdDesc(Long userId, Pageable pageable); diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index a9c5113a..b010a0a9 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -26,6 +26,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -128,12 +129,20 @@ private JsonNode asJson(Document doc) { } @Transactional(readOnly = true) - public ResponseEntity> fetchMyHisotry(Long userId, Long lastId, int size) { + public ResponseEntity> fetchMyHistory(Long userId, UUID lastId, int size) { PageRequest pr = PageRequest.of(0, size); Page page; - if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(userId, pr); - else page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(userId, lastId, pr); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(user.getId(), pr); + else { + Long lastIds = historyRepository.findByUserIdAndUuid(user.getId(), lastId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_PAGE_NOT_FOUND)); + + page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(user.getId(), lastIds, pr); + } return ResponseEntity.ok(page.getContent().stream().map(HistoryPageResponse::from).toList()); } From 8109ef6aa930bda96af9cb697d146aaee782fcbb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 18:40:56 +0900 Subject: [PATCH 307/527] refactor: add preChoice domain --- .../java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 0eeb1876..0c63cc64 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -26,6 +26,7 @@ public class GameSessionMongo { private LocalDateTime updatedAt; private String background; + private String preChoice; private String location; private Integer progress; private List stage; From b9830ce1491c81edb1d958255d574e30a8c8a52b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 18:41:32 +0900 Subject: [PATCH 308/527] refactor: add getChoiceEventType to static --- src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java index e3324409..2ee47a05 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceEventType.java @@ -21,7 +21,7 @@ public enum ChoiceEventType { this.ChoiceEventChance = ChoiceEventChance; } - public ChoiceEventType getChoiceEventType() { + public static ChoiceEventType getChoiceEventType() { int rand = random.nextInt(100) + 1; int cumulative = 0; From 1ed9becefe6f3a5e09cfacda0689c317956145e8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 18:42:26 +0900 Subject: [PATCH 309/527] refactor: change domain type eventType --- .../demo/dto/gamesession/CreateGameChoiceRequest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java index c5940deb..3e489985 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.gamesession; +import com.scriptopia.demo.domain.ChoiceEventType; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -13,7 +14,7 @@ public class CreateGameChoiceRequest { private String currentStory; private String location; private String currentChoice; - private String eventType; + private ChoiceEventType eventType; private Integer npcRank; private PlayerInfo playerInfo; private List itemInfo; From 9f922813bc72e3183ff9bdc09304be2d4acf209c Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:53:05 +0900 Subject: [PATCH 310/527] feat: create folder client --- .../demo/utils/{ => client}/GoogleClient.java | 19 ++++++------------- .../demo/utils/{ => client}/KakaoClient.java | 2 +- .../demo/utils/{ => client}/NaverClient.java | 2 +- src/main/resources/application.yml | 2 +- 4 files changed, 9 insertions(+), 16 deletions(-) rename src/main/java/com/scriptopia/demo/utils/{ => client}/GoogleClient.java (83%) rename src/main/java/com/scriptopia/demo/utils/{ => client}/KakaoClient.java (98%) rename src/main/java/com/scriptopia/demo/utils/{ => client}/NaverClient.java (98%) diff --git a/src/main/java/com/scriptopia/demo/utils/GoogleClient.java b/src/main/java/com/scriptopia/demo/utils/client/GoogleClient.java similarity index 83% rename from src/main/java/com/scriptopia/demo/utils/GoogleClient.java rename to src/main/java/com/scriptopia/demo/utils/client/GoogleClient.java index efb21975..67fd955b 100644 --- a/src/main/java/com/scriptopia/demo/utils/GoogleClient.java +++ b/src/main/java/com/scriptopia/demo/utils/client/GoogleClient.java @@ -1,5 +1,6 @@ -package com.scriptopia.demo.utils; +package com.scriptopia.demo.utils.client; +import com.scriptopia.demo.config.OAuthProperties; import com.scriptopia.demo.domain.Provider; import com.scriptopia.demo.dto.oauth.OAuthUserInfo; import lombok.RequiredArgsConstructor; @@ -18,15 +19,7 @@ @RequiredArgsConstructor public class GoogleClient { - @Value("${oauth.google.client-id}") - private String clientId; - - @Value("${oauth.google.client-secret}") - private String clientSecret; - - @Value("${oauth.google.redirect-uri}") - private String redirectUri; - + private final OAuthProperties props; private final RestTemplate restTemplate = new RestTemplate(); public OAuthUserInfo getUserInfo(String code) { @@ -35,9 +28,9 @@ public OAuthUserInfo getUserInfo(String code) { Map params = new HashMap<>(); params.put("code", code); - params.put("client_id", clientId); - params.put("client_secret", clientSecret); - params.put("redirect_uri", redirectUri); + params.put("client_id", props.getGoogle().getClientId()); + params.put("client_secret", props.getGoogle().getClientSecret()); + params.put("redirect_uri", props.getGoogle().getRedirectUri()); params.put("grant_type", "authorization_code"); ResponseEntity tokenResponse = diff --git a/src/main/java/com/scriptopia/demo/utils/KakaoClient.java b/src/main/java/com/scriptopia/demo/utils/client/KakaoClient.java similarity index 98% rename from src/main/java/com/scriptopia/demo/utils/KakaoClient.java rename to src/main/java/com/scriptopia/demo/utils/client/KakaoClient.java index eba1cf9a..a0aaca5c 100644 --- a/src/main/java/com/scriptopia/demo/utils/KakaoClient.java +++ b/src/main/java/com/scriptopia/demo/utils/client/KakaoClient.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils; +package com.scriptopia.demo.utils.client; import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.config.OAuthProperties; diff --git a/src/main/java/com/scriptopia/demo/utils/NaverClient.java b/src/main/java/com/scriptopia/demo/utils/client/NaverClient.java similarity index 98% rename from src/main/java/com/scriptopia/demo/utils/NaverClient.java rename to src/main/java/com/scriptopia/demo/utils/client/NaverClient.java index 80429d10..1d6b17ce 100644 --- a/src/main/java/com/scriptopia/demo/utils/NaverClient.java +++ b/src/main/java/com/scriptopia/demo/utils/client/NaverClient.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.utils; +package com.scriptopia.demo.utils.client; import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.config.OAuthProperties; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c74a8648..e4e63124 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,7 +41,7 @@ oauth: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_SECRET_KEY} redirect-uri: ${GOOGLE_REDIRECT_URI} - scope: email profile + scope: openid email profile kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_SECRET} From 71401a65ff7463f0f8a6db82f4cd35e2689daca1 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:54:24 +0900 Subject: [PATCH 311/527] feat: update oauth controller auth method seperate oauth filter chain --- .../scriptopia/demo/config/SecurityConfig.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 522899b4..d1d50f7c 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -44,7 +44,7 @@ public PasswordEncoder passwordEncoder() { @Bean @Order(1) - public SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + public SecurityFilterChain authChain(HttpSecurity http) throws Exception { http.securityMatcher("/auth/**") .authorizeHttpRequests(auth -> auth @@ -54,6 +54,20 @@ public SecurityFilterChain publicChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean + @Order(2) + public SecurityFilterChain oAuthChain(HttpSecurity http) throws Exception { + + http.securityMatcher("/oauth/**") + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + + + @Bean @Order(99) // public 체인보다 뒤에서 동작 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { From fc894dc6fc855d232f97c0d3bfe80e1caa2b985c Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:54:57 +0900 Subject: [PATCH 312/527] feat: remove role in oAuthController uri --- .../scriptopia/demo/controller/OAuthController.java | 2 +- .../com/scriptopia/demo/service/OAuthService.java | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index 29db3b1e..e38521e4 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/public/oauth") +@RequestMapping("/oauth") @RequiredArgsConstructor public class OAuthController { diff --git a/src/main/java/com/scriptopia/demo/service/OAuthService.java b/src/main/java/com/scriptopia/demo/service/OAuthService.java index f70a354d..f5b4edba 100644 --- a/src/main/java/com/scriptopia/demo/service/OAuthService.java +++ b/src/main/java/com/scriptopia/demo/service/OAuthService.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.config.OAuthProperties; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.localaccount.LoginResponse; import com.scriptopia.demo.dto.oauth.LoginStatus; import com.scriptopia.demo.dto.oauth.OAuthLoginResponse; import com.scriptopia.demo.dto.oauth.OAuthUserInfo; @@ -13,22 +12,22 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.SocialAccountRepository; import com.scriptopia.demo.repository.UserRepository; -import com.scriptopia.demo.utils.GoogleClient; +import com.scriptopia.demo.utils.client.GoogleClient; import com.scriptopia.demo.utils.JwtProvider; -import com.scriptopia.demo.utils.KakaoClient; -import com.scriptopia.demo.utils.NaverClient; +import com.scriptopia.demo.utils.client.KakaoClient; +import com.scriptopia.demo.utils.client.NaverClient; import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.HttpClientErrorException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -186,7 +185,7 @@ public String buildAuthorizationUrl(String provider) { "?client_id=" + props.getGoogle().getClientId() + "&redirect_uri=" + props.getGoogle().getRedirectUri() + "&response_type=code" + - "&scope=" + props.getGoogle().getScope(); + "&scope=" + URLEncoder.encode(props.getGoogle().getScope(), StandardCharsets.UTF_8); case "KAKAO": return "https://kauth.kakao.com/oauth/authorize" + "?client_id=" + props.getKakao().getClientId() + From 359325656c2af307f12ba25c7030e53c1c0cdde2 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:56:05 +0900 Subject: [PATCH 313/527] feat: remove debug log in JwtFilter --- src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 90df17aa..2cd574eb 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -35,9 +35,6 @@ public class JwtAuthFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { - - System.out.println(">>> JwtAuthFilter 실행됨, path=" + req.getServletPath()); - String authHeader = req.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { setErrorResponse(res, ErrorCode.E_400_MISSING_JWT); From 1f19ae4c01bdaf58a2f5d0b9a6b767fab1d35b6a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 19:59:15 +0900 Subject: [PATCH 314/527] Merge branch 'develop/main' into feature/game-choice-generation-124 --- .../controller/GameSessionController.java | 16 +++++++ .../com/scriptopia/demo/domain/NpcGrade.java | 48 +++++++++++++------ .../demo/service/GameSessionService.java | 44 ++++++++++++++--- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 3c9e80ac..6b7fff75 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.domain.mongo.GameSessionMongo; +import com.scriptopia.demo.domain.mongo.ItemDefMongo; +import com.scriptopia.demo.dto.gamesession.CreateGameChoiceRequest; import com.scriptopia.demo.dto.gamesession.GameChoiceRequest; import com.scriptopia.demo.dto.gamesession.StartGameRequest; import com.scriptopia.demo.dto.gamesession.StartGameResponse; @@ -47,6 +50,19 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } + /** + * 테스트 중 + */ + @PostMapping("/test") + public ResponseEntity testGame( + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + CreateGameChoiceRequest response = gameSessionService.mapToCreateGameChoiceRequest(userId); + return ResponseEntity.ok(response); + } + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java index b47f1cf8..b3e009d7 100644 --- a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java +++ b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java @@ -6,33 +6,39 @@ @Getter public enum NpcGrade { - GRADE1(1, 70, 18), - GRADE2(2, 105,26), - GRADE3(3, 140,35), - GRADE4(4,152,38), - GRADE5(5, 188,47), - GRADE6(6, 204,51), - GRADE7(7, 248,62), - GRADE8(8, 268,67), - GRADE9(9, 320,80), - GRADE10(10, 344,86), - GRADE11(11, 707, 101), - GRADE12(12, 963,107); + GRADE1(1, 70, 18, 14, 8, 4), + GRADE2(2, 105,26, 16, 9, 5), + GRADE3(3, 140,35, 14, 9, 6), + GRADE4(4,152,38, 12, 12, 7), + GRADE5(5, 188,47, 10, 12, 9), + GRADE6(6, 204,51, 9, 12 ,11), + GRADE7(7, 248,62, 8, 11, 13), + GRADE8(8, 268,67, 7, 10, 13), + GRADE9(9, 320,80, 6, 10 , 12), + GRADE10(10, 344,86, 3, 5, 7), + GRADE11(11, 707, 101, 1, 1, 2), + GRADE12(12, 963,107, 0, 1, 1); private final int gradeNumber; private final int defense; private final int attack; + private final int chapter1; + private final int chapter2; + private final int chapter3; + private static final SecureRandom random = new SecureRandom(); NpcGrade(int gradeNumber, int defense, - int attack) { + int attack, int chapter1, int chapter2, int chapter3) { this.gradeNumber = gradeNumber; this.defense = defense; this.attack = attack; + this.chapter1 = chapter1; + this.chapter2 = chapter2; + this.chapter3 = chapter3; } - public static NpcGrade getByGradeNumber(int gradeNumber) { for (NpcGrade grade : NpcGrade.values()) { if (grade.getGradeNumber() == gradeNumber) { @@ -42,6 +48,20 @@ public static NpcGrade getByGradeNumber(int gradeNumber) { return null; } + public static Integer getNpcNumberByRandom (int currentChapter) { + int rand = random.nextInt(100) + 1; // 1~100 + int cumulative = 0; + + for (NpcGrade grade : NpcGrade..values()) { + if (rand <= cumulative) { + return grade; + } + } + return 12; + } + + + // ±10% 랜덤 방어력 public int getRandomDefense() { int delta = (int)(defense * 0.1); diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 3fff68f2..80554071 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -151,6 +151,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { mongoSession.setSceneType(SceneType.CHOICE); // 시작은 choice 기본값 mongoSession.setStartedAt(LocalDateTime.now()); mongoSession.setUpdatedAt(LocalDateTime.now()); + mongoSession.setPreChoice(null); mongoSession.setBackground(externalGame.getBackgroundStory()); mongoSession.setLocation(externalGame.getLocation()); mongoSession.setProgress(0); @@ -279,25 +280,54 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { @Transactional - public GameSessionMongo createChoice(Long userId){ + public CreateGameChoiceRequest mapToCreateGameChoiceRequest(Long userId) { + // 1. MySQL에서 게임 세션 확인 if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } + GameSession gameSession = gameSessionRepository.findByMongoId(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); - String gameId = gameSession.getMongoId(); - - /** - * 1. mongoDB에서 정보를 가져오기 gameId를 통해 정보를 가져옴 - */ + String gameId = gameSession.getMongoId(); GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); - return gameSessionMongo; + // 3. DTO로 매핑 + CreateGameChoiceRequest response = new CreateGameChoiceRequest(); + response.setWorldView(gameSessionMongo.getHistoryInfo().getWorldView()); + + // + response.setCurrentStory(gameSessionMongo.getHistoryInfo().getBackgroundStory()); + response.setCurrentChoice(null); // 초기값 + + + response.setLocation(gameSessionMongo.getLocation()); + response.setEventType(ChoiceEventType.getChoiceEventType()); + response.setNpcRank(NpcGrade); + + // playerInfo 매핑 + CreateGameChoiceRequest.PlayerInfo playerInfo = new CreateGameChoiceRequest.PlayerInfo(); + playerInfo.setName(gameSessionMongo.getPlayerInfo().getName()); + playerInfo.setTrait(gameSessionMongo.getPlayerInfo().getTrait()); + response.setPlayerInfo(playerInfo); + + // itemInfo 매핑 + List itemInfoList = gameSessionMongo.getInventory().stream() + .map(inv -> { + CreateGameChoiceRequest.ItemInfo itemInfo = new CreateGameChoiceRequest.ItemInfo(); + ItemDefMongo itemDef = itemDefMongoRepository.findById(inv.getItemDefId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + itemInfo.setName(itemDef.getName()); + itemInfo.setDescription(itemDef.getDescription()); + return itemInfo; + }).toList(); + response.setItemInfo(itemInfoList); + + return response; } } From a14a276253a110f9599c1fa794566c15add72a3c Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 7 Sep 2025 20:12:54 +0900 Subject: [PATCH 315/527] PublicSharedController UUID modify --- .../demo/controller/MyPageController.java | 2 +- .../controller/PublicSharedGameController.java | 18 ++++++++++++++---- .../com/scriptopia/demo/domain/SharedGame.java | 4 ++++ .../demo/dto/history/HistoryPageResponse.java | 5 +++-- .../PublicSharedGameDetailResponse.java | 3 ++- .../demo/repository/HistoryRepository.java | 2 +- .../demo/service/SharedGameService.java | 8 ++++---- 7 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index 72881676..b86abaef 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -58,7 +58,7 @@ public ResponseEntity share(Authentication authentication, @PathVariable UUID public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { Long userId = Long.valueOf(authentication.getName()); - sharedGameService.deletesharedGame(userId, uuid); + sharedGameService.deleteSharedGame(userId, uuid); return ResponseEntity.ok("게임이 삭제되었습니다."); } diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java index 341fbc82..d9cab3aa 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -9,23 +9,33 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.UUID; @RestController -@RequestMapping("/public/games/shared") +@RequestMapping("/games/shared") @RequiredArgsConstructor public class PublicSharedGameController { private final SharedGameService sharedGameService; - @GetMapping("/{sharedGameId}") - public ResponseEntity getSharedGameDetail(@PathVariable Long sharedGameId) { - return sharedGameService.getDetailedSharedGame(sharedGameId); + /* + 게임공유 : 공유된 게임 상세 조회 + */ + @GetMapping("/{uuid}") + public ResponseEntity getSharedGameDetail(@PathVariable UUID uuid) { + return sharedGameService.getDetailedSharedGame(uuid); } + /* + 게임공유 : 공유된 게임 태그 조회 + */ @GetMapping("/tags") public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); } + /* + 게임공유 : 공유된 게임 목록 조회 + */ @GetMapping public ResponseEntity> getPublicSharedGames(Authentication authentication, @RequestParam(required = false) Long lastId, diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index e077b5ee..b2ad6526 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -45,6 +45,10 @@ public void generateUuid() { if(uuid == null) { uuid = UUID.randomUUID(); } + + if(sharedAt == null) { + sharedAt = LocalDateTime.now(); + } } public static SharedGame from(User user, History h) { diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java index 5fd64fc8..922a7535 100644 --- a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java @@ -4,10 +4,11 @@ import lombok.Data; import java.time.LocalDateTime; +import java.util.UUID; @Data public class HistoryPageResponse { - private Long id; + private UUID uuid; private String title; private Long score; private String thumbnail_url; @@ -15,7 +16,7 @@ public class HistoryPageResponse { public static HistoryPageResponse from(History h) { HistoryPageResponse dto = new HistoryPageResponse(); - dto.setId(h.getId()); + dto.setUuid(h.getUuid()); dto.setTitle(h.getTitle()); dto.setScore(h.getScore()); dto.setThumbnail_url(h.getThumbnailUrl()); diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index cede952a..f7f22112 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -5,10 +5,11 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; @Data public class PublicSharedGameDetailResponse { - private Long sharedGameId; + private UUID sharedGameUUID; private String nickname; private String thumbnailUrl; private Long totalPlayed; diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java index 70d4ae14..245f0217 100644 --- a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -14,7 +14,7 @@ public interface HistoryRepository extends JpaRepository { @Query("select h from History h where h.uuid = :uuid") Optional findByUuid(@Param("uuid") UUID uuid); - @Query("select h.id from History h where h.user.id = : userId and h.uuid = :uuid") + @Query("select h.id from History h where h.user.id = :userId and h.uuid = :uuid") Optional findByUserIdAndUuid(@Param("userId") Long userId, @Param("uuid") UUID uuid); Page findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long lastId, Pageable pageable); diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 127e2854..0327d717 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -79,7 +79,7 @@ public ResponseEntity getMySharedGames(Long userId) { } @Transactional - public void deletesharedGame(Long id, UUID uuid) { + public void deleteSharedGame(Long id, UUID uuid) { User user = userRepository.findById(id) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); @@ -93,8 +93,8 @@ public void deletesharedGame(Long id, UUID uuid) { sharedGameRepository.delete(game); } - public ResponseEntity getDetailedSharedGame(Long sharedId) { - SharedGame game = sharedGameRepository.findById(sharedId) + public ResponseEntity getDetailedSharedGame(UUID uuid) { + SharedGame game = sharedGameRepository.findByUuid(uuid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); List tagName = gameTagRepository.findTagNamesBySharedGameId(game.getId()); @@ -102,7 +102,7 @@ public ResponseEntity getDetailedSharedGame(Long sharedId) { List score = sharedGameScoreRepository.findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(game.getId()); PublicSharedGameDetailResponse dto = new PublicSharedGameDetailResponse(); - dto.setSharedGameId(game.getId()); + dto.setSharedGameUUID(game.getUuid()); dto.setNickname(game.getUser().getNickname()); dto.setThumbnailUrl(game.getThumbnailUrl()); dto.setTotalPlayed(game.getTotalPlayed()); From 8668d809f196bc9f7476c64650236045c2639cb2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 20:43:59 +0900 Subject: [PATCH 316/527] refactor: add capter Npc Ramdom probabilty --- .../com/scriptopia/demo/domain/NpcGrade.java | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java index b3e009d7..d158f48b 100644 --- a/src/main/java/com/scriptopia/demo/domain/NpcGrade.java +++ b/src/main/java/com/scriptopia/demo/domain/NpcGrade.java @@ -3,40 +3,38 @@ import lombok.Getter; import java.security.SecureRandom; +import java.util.List; @Getter public enum NpcGrade { - GRADE1(1, 70, 18, 14, 8, 4), - GRADE2(2, 105,26, 16, 9, 5), - GRADE3(3, 140,35, 14, 9, 6), - GRADE4(4,152,38, 12, 12, 7), - GRADE5(5, 188,47, 10, 12, 9), - GRADE6(6, 204,51, 9, 12 ,11), - GRADE7(7, 248,62, 8, 11, 13), - GRADE8(8, 268,67, 7, 10, 13), - GRADE9(9, 320,80, 6, 10 , 12), - GRADE10(10, 344,86, 3, 5, 7), - GRADE11(11, 707, 101, 1, 1, 2), - GRADE12(12, 963,107, 0, 1, 1); + GRADE1(1, 70, 18, List.of(14, 8, 4)), + GRADE2(2, 105, 26, List.of(16, 9, 5)), + GRADE3(3, 140, 35, List.of(14, 9, 6)), + GRADE4(4, 152, 38, List.of(12, 12, 7)), + GRADE5(5, 188, 47, List.of(10, 12, 9)), + GRADE6(6, 204, 51, List.of(9, 12, 11)), + GRADE7(7, 248, 62, List.of(8, 11, 13)), + GRADE8(8, 268, 67, List.of(7, 10, 13)), + GRADE9(9, 320, 80, List.of(6, 10, 12)), + GRADE10(10, 344, 86, List.of(3, 5, 7)), + GRADE11(11, 707, 101, List.of(1, 1, 2)), + GRADE12(12, 963, 107, List.of(0, 1, 1)); + private final int gradeNumber; private final int defense; private final int attack; - private final int chapter1; - private final int chapter2; - private final int chapter3; + private final List chapter; private static final SecureRandom random = new SecureRandom(); NpcGrade(int gradeNumber, int defense, - int attack, int chapter1, int chapter2, int chapter3) { + int attack, List chapter) { this.gradeNumber = gradeNumber; this.defense = defense; this.attack = attack; - this.chapter1 = chapter1; - this.chapter2 = chapter2; - this.chapter3 = chapter3; + this.chapter = chapter; } public static NpcGrade getByGradeNumber(int gradeNumber) { @@ -48,18 +46,21 @@ public static NpcGrade getByGradeNumber(int gradeNumber) { return null; } - public static Integer getNpcNumberByRandom (int currentChapter) { + public static Integer getNpcNumberByRandom(int currentChapter) { int rand = random.nextInt(100) + 1; // 1~100 int cumulative = 0; - for (NpcGrade grade : NpcGrade..values()) { + for (NpcGrade grade : NpcGrade.values()) { + int weight = grade.getChapter().get(currentChapter - 1); + + cumulative += weight; if (rand <= cumulative) { - return grade; + return grade.getGradeNumber(); } } - return 12; - } + return NpcGrade.GRADE12.getGradeNumber(); + } // ±10% 랜덤 방어력 From 65f80864156845c50b5b2ff23159cf0b4bc7fe76 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 20:44:29 +0900 Subject: [PATCH 317/527] feat: create 404 ERROR CODE PAGE NOT FOUND --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 520088c0..181c04b4 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -66,6 +66,11 @@ public enum ErrorCode { E_404_GAME_SESSION_NOT_FOUND("E404006", "게임을 불러올 수 없습니다.", HttpStatus.NOT_FOUND), E_404_STORED_GAME_NOT_FOUND("E404007", "저장된 게임이 존재하지 않습니다.", HttpStatus.NOT_FOUND), E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), + E_404_ITEM_NOT_FOUND("E404009", "아이템이 없습니다.", HttpStatus.NOT_FOUND), + E_404_PAGE_NOT_FOUND("E404010", "페이지가 없습니다.", HttpStatus.NOT_FOUND), + + + //409 Conflict From eb28227e9ab300b34094d990343472289bccc367 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 20:54:35 +0900 Subject: [PATCH 318/527] feat: create CreateGameChoiceResponse dto --- .../gamesession/CreateGameChoiceResponse.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java new file mode 100644 index 00000000..3c16a26f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java @@ -0,0 +1,43 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateGameChoiceResponse { + + private ChoiceInfo choiceInfo; + private NpcInfo npcInfo; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ChoiceInfo { + private String story; + private List choice; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ChoiceOption { + private String detail; + private String stats; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class NpcInfo { + private String name; + private Integer rank; + private String trait; + private String npcWeaponName; + private String npcWeaponDescription; + } +} From 2daa18e9aeeb5f98710a1e887260ee92e8b82e49 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:55:28 +0900 Subject: [PATCH 319/527] wip: update auth method --- .../demo/config/SecurityConfig.java | 47 +++++++++++++++---- .../demo/controller/AuctionController.java | 15 +++--- .../exception/GlobalExceptionHandler.java | 14 +++--- .../demo/service/LocalAccountService.java | 5 +- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index d1d50f7c..7b6141d2 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -2,14 +2,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.dto.exception.ErrorResponse; -import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; -import java.nio.charset.StandardCharsets; import java.util.List; import static com.scriptopia.demo.exception.ErrorCode.E_403; @@ -37,11 +35,6 @@ public PasswordEncoder passwordEncoder() { private final JwtProvider jwtProvider; -// @Bean -// public JwtAuthFilter jwtAuthFilter(){ -// return new JwtAuthFilter(jwtProvider); -// } - @Bean @Order(1) public SecurityFilterChain authChain(HttpSecurity http) throws Exception { @@ -51,6 +44,12 @@ public SecurityFilterChain authChain(HttpSecurity http) throws Exception { .anyRequest().permitAll() ) .csrf(AbstractHttpConfigurer::disable); + + http.addFilterBefore((request, response, chain) -> { + System.out.println("[SecurityChain-1]"); + chain.doFilter(request, response); + }, UsernamePasswordAuthenticationFilter.class); + return http.build(); } @@ -63,6 +62,30 @@ public SecurityFilterChain oAuthChain(HttpSecurity http) throws Exception { .anyRequest().permitAll() ) .csrf(AbstractHttpConfigurer::disable); + + http.addFilterBefore((request, response, chain) -> { + System.out.println("[SecurityChain-2]"); + chain.doFilter(request, response); + }, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + @Order(3) + public SecurityFilterChain publicTradesChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/trades") // 공개 체인: GET /trades 만 + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/trades").permitAll() + ) + .csrf(AbstractHttpConfigurer::disable); + + http.addFilterBefore((request, response, chain) -> { + System.out.println("[SecurityChain-3]"); + chain.doFilter(request, response); + }, UsernamePasswordAuthenticationFilter.class); + return http.build(); } @@ -71,6 +94,14 @@ public SecurityFilterChain oAuthChain(HttpSecurity http) throws Exception { @Bean @Order(99) // public 체인보다 뒤에서 동작 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + + http.addFilterBefore((request, response, chain) -> { + System.out.println("[SecurityChain-99]"); + chain.doFilter(request, response); + }, UsernamePasswordAuthenticationFilter.class); + + http .securityMatcher("/**") .csrf(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 60fa41b0..76eea1a9 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -10,12 +10,13 @@ @RestController @RequiredArgsConstructor +@RequestMapping("/trades") public class AuctionController { private final AuctionService auctionService; - @GetMapping("/public/trades") + @GetMapping("/trades") public ResponseEntity getTrades( @RequestBody TradeFilterRequest requestDto) { @@ -24,7 +25,7 @@ public ResponseEntity getTrades( } - @PostMapping("/user/trades/{auctionId}/purchase") + @PostMapping("/trades/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, Authentication authentication) { @@ -35,7 +36,7 @@ public ResponseEntity purchaseItem( return ResponseEntity.ok(result); } - @GetMapping("/user/trades/me") + @GetMapping("/trades/me") public ResponseEntity mySaleItems( @RequestBody MySaleItemRequest requestDto, Authentication authentication) { @@ -46,7 +47,7 @@ public ResponseEntity mySaleItems( return ResponseEntity.ok(result); } - @PostMapping("/user/trades") + @PostMapping("/trades") public ResponseEntity createAuction(@RequestBody AuctionRequest dto, Authentication authentication ){ @@ -54,7 +55,7 @@ public ResponseEntity createAuction(@RequestBody AuctionRequest dto, return ResponseEntity.ok(auctionService.createAuction(dto, userId)); } - @DeleteMapping("/user/trades/{auctionId}") + @DeleteMapping("/trades/{auctionId}") public ResponseEntity cancelMySaleItem( @PathVariable String auctionId, Authentication authentication) { @@ -65,7 +66,7 @@ public ResponseEntity cancelMySaleItem( } - @GetMapping("/user/trades/me/history") + @GetMapping("/trades/me/history") public ResponseEntity settlementHistory( @RequestBody SettlementHistoryRequest requestDto, Authentication authentication) { @@ -76,7 +77,7 @@ public ResponseEntity settlementHistory( return ResponseEntity.ok(result); } - @PatchMapping("/user/trades/{settlementId}/confirm") + @PatchMapping("/trades/{settlementId}/confirm") public ResponseEntity confirmItem( @PathVariable String settlementId, Authentication authentication) { diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index a61e62af..a557005b 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -71,11 +71,11 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse(ErrorCode.E_500)); - } +// @ExceptionHandler(Exception.class) +// public ResponseEntity handleGeneralException(Exception ex) { +// +// return ResponseEntity +// .status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(new ErrorResponse(ErrorCode.E_500)); +// } } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index dd81bace..1d3cd1e9 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -55,9 +55,9 @@ public class LocalAccountService { @Transactional public void resetPassword(String token,String newPassword) { + String key = "reset:token:" + token; String email = redisTemplate.opsForValue().get(key); - System.out.println(key); if (email == null) { throw new CustomException(ErrorCode.E_401); } @@ -82,6 +82,9 @@ public void verifyEmail(VerifyEmailRequest request) { @Transactional public void sendVerificationCode(String email) { + if (localAccountRepository.existsByEmail(email)){ + throw new CustomException(ErrorCode.E_409_EMAIL_TAKEN); + } String code = String.format("%06d", (int)(Math.random() * 999999)); mailService.saveCode(email, code); mailService.sendVerificationCode(email, code); From 6b2400147f8d2bae4755ac8e35c7697edc1d5c92 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 22:17:28 +0900 Subject: [PATCH 320/527] wip: nothing to chanage --- .../com/scriptopia/demo/repository/AuctionRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java index d9a07611..1c49c201 100644 --- a/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/AuctionRepository.java @@ -41,10 +41,10 @@ WHERE LOWER(idf.name) LIKE LOWER(CONCAT('%', :itemName, '%')) AND (:maxPrice IS NULL OR a.price <= :maxPrice) AND (:stat IS NULL OR id.mainStat = :stat) AND ( - :effectGrades IS NULL + :effectGrades IS NULL OR EXISTS ( - SELECT 1 FROM ItemEffect ie2 - WHERE ie2.itemDef = id + SELECT 1 FROM ItemEffect ie2 + WHERE ie2.itemDef = id AND ie2.effectGradeDef.effectProbability IN :effectGrades ) ) From a5001c47c246f8c558ea501d1338474ebd31b6d0 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 22:18:08 +0900 Subject: [PATCH 321/527] refactor: add stroy title --- .../java/com/scriptopia/demo/domain/ChoiceResultType.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index c724adc7..f4a4bdff 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -9,7 +9,7 @@ public enum ChoiceResultType { BATTLE(40), CHOICE(30), SHOP(10), - NONE(50); + NONE(30); private final int nextEventType; @@ -20,8 +20,8 @@ public enum ChoiceResultType { } - public ChoiceResultType nextResultType() { - int rand = random.nextInt(nextEventType); + public static ChoiceResultType nextResultType() { + int rand = random.nextInt(100) + 1; int cumulative = 0; for(ChoiceResultType type : values()) { From 61ac7c8240bc40bfbcf98b8654309a8e878cbb0e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 22:22:11 +0900 Subject: [PATCH 322/527] refactor: add ChoiceInfo to title --- .../demo/dto/gamesession/CreateGameChoiceRequest.java | 2 ++ .../demo/dto/gamesession/CreateGameChoiceResponse.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java index 3e489985..be75b9b4 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.dto.gamesession; import com.scriptopia.demo.domain.ChoiceEventType; +import com.scriptopia.demo.domain.Stat; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -14,6 +15,7 @@ public class CreateGameChoiceRequest { private String currentStory; private String location; private String currentChoice; + private List choiceStat; private ChoiceEventType eventType; private Integer npcRank; private PlayerInfo playerInfo; diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java index 3c16a26f..0af4abe1 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceResponse.java @@ -19,6 +19,7 @@ public class CreateGameChoiceResponse { @NoArgsConstructor public static class ChoiceInfo { private String story; + private String title; private List choice; } @@ -27,7 +28,6 @@ public static class ChoiceInfo { @NoArgsConstructor public static class ChoiceOption { private String detail; - private String stats; } @Data From bd8267944a9ea25c1265beb3d6375e0234320717 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sun, 7 Sep 2025 22:22:55 +0900 Subject: [PATCH 323/527] feat: create test Controller and connect MongoService --- .../controller/GameSessionController.java | 9 +- .../demo/service/GameSessionService.java | 150 ++++++++++++++++-- 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 6b7fff75..7b27000b 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -4,10 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.domain.mongo.GameSessionMongo; import com.scriptopia.demo.domain.mongo.ItemDefMongo; -import com.scriptopia.demo.dto.gamesession.CreateGameChoiceRequest; -import com.scriptopia.demo.dto.gamesession.GameChoiceRequest; -import com.scriptopia.demo.dto.gamesession.StartGameRequest; -import com.scriptopia.demo.dto.gamesession.StartGameResponse; +import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.service.GameSessionService; import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; @@ -54,12 +51,12 @@ public ResponseEntity startNewGame( * 테스트 중 */ @PostMapping("/test") - public ResponseEntity testGame( + public ResponseEntity testGame( Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - CreateGameChoiceRequest response = gameSessionService.mapToCreateGameChoiceRequest(userId); + GameSessionMongo response = gameSessionService.mapToCreateGameChoiceRequest(userId); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 80554071..d98d5b0b 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.*; +import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; @@ -23,6 +24,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -35,6 +37,7 @@ public class GameSessionService { private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; private final MongoClient mongo; + private final WebInvocationPrivilegeEvaluator privilegeEvaluator; public boolean duplcatedGameSession(Long userId) { User user = userRepository.findById(userId) @@ -280,9 +283,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { @Transactional - public CreateGameChoiceRequest mapToCreateGameChoiceRequest(Long userId) { + public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { - // 1. MySQL에서 게임 세션 확인 if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } @@ -296,25 +298,62 @@ public CreateGameChoiceRequest mapToCreateGameChoiceRequest(Long userId) { GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + CreateGameChoiceRequest fastApiRequest = new CreateGameChoiceRequest(); + fastApiRequest.setWorldView(gameSessionMongo.getHistoryInfo().getWorldView()); + fastApiRequest.setLocation(gameSessionMongo.getLocation()); - // 3. DTO로 매핑 - CreateGameChoiceRequest response = new CreateGameChoiceRequest(); - response.setWorldView(gameSessionMongo.getHistoryInfo().getWorldView()); - // - response.setCurrentStory(gameSessionMongo.getHistoryInfo().getBackgroundStory()); - response.setCurrentChoice(null); // 초기값 + List statInfo = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + statInfo.add(Stat.getRandomMainStat()); + } + + + fastApiRequest.setChoiceStat(statInfo); + + + int progress = gameSessionMongo.getProgress(); + List stage = gameSessionMongo.getStage(); + int currentEventStage = stage.get(progress); // 짝수면 -1한 history의 이야기를 넣어줘야 함 + + if (currentEventStage == 0) { + fastApiRequest.setCurrentStory(gameSessionMongo.getBackground()); + fastApiRequest.setCurrentChoice(gameSessionMongo.getPreChoice()); + } else { + if (currentEventStage % 2 == 1) { + fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getBackgroundStory()); + fastApiRequest.setCurrentChoice(null); + } else { + fastApiRequest.setCurrentChoice(null); + switch (currentEventStage) { + case 2: + fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue1Content()); + break; + case 4: + fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue2Content()); + break; + case 6: + fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue3Content()); + break; + } + } + } + ChoiceEventType currentEventType = ChoiceEventType.getChoiceEventType(); + fastApiRequest.setEventType(currentEventType); - response.setLocation(gameSessionMongo.getLocation()); - response.setEventType(ChoiceEventType.getChoiceEventType()); - response.setNpcRank(NpcGrade); + int currentNpcRank = 4; + if (currentEventType == ChoiceEventType.LIVING) { + int currentChapter = progress / (stage.size() / 3 + 1) + 1; + currentNpcRank = NpcGrade.getNpcNumberByRandom(currentChapter); + fastApiRequest.setNpcRank(currentNpcRank); + } // playerInfo 매핑 CreateGameChoiceRequest.PlayerInfo playerInfo = new CreateGameChoiceRequest.PlayerInfo(); playerInfo.setName(gameSessionMongo.getPlayerInfo().getName()); playerInfo.setTrait(gameSessionMongo.getPlayerInfo().getTrait()); - response.setPlayerInfo(playerInfo); + fastApiRequest.setPlayerInfo(playerInfo); // itemInfo 매핑 List itemInfoList = gameSessionMongo.getInventory().stream() @@ -326,8 +365,91 @@ public CreateGameChoiceRequest mapToCreateGameChoiceRequest(Long userId) { itemInfo.setDescription(itemDef.getDescription()); return itemInfo; }).toList(); - response.setItemInfo(itemInfoList); + fastApiRequest.setItemInfo(itemInfoList); + + + // WebClient 인스턴스 생성 + WebClient client = WebClient.builder() + .baseUrl("http://localhost:8000") + .build(); + + // FastAPI 호출(테스트용 추후 변경 가능) + CreateGameChoiceResponse createGameChoiceResponse = client.post() + .uri("/games/choice") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(fastApiRequest) // + .retrieve() + .bodyToMono(CreateGameChoiceResponse.class) + .block(); // + + // 응답 검증 + if (createGameChoiceResponse == null) { + throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); + } + + + gameSessionMongo.setUpdatedAt(LocalDateTime.now()); + gameSessionMongo.setBackground(createGameChoiceResponse.getChoiceInfo().getStory()); + gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); + + + NpcInfoMongo npcInfoMongo = NpcInfoMongo.builder() + .rank(currentNpcRank) + .name(createGameChoiceResponse.getNpcInfo().getName()) + .trait(createGameChoiceResponse.getNpcInfo().getTrait()) + .NpcWeaponName(createGameChoiceResponse.getNpcInfo().getNpcWeaponName()) + .NpcWeaponDescription(createGameChoiceResponse.getNpcInfo().getNpcWeaponDescription()) + .build(); + + gameSessionMongo.setNpcInfo(npcInfoMongo); + + + + List choiceList = new ArrayList<>(); + for (int i = 0; i < createGameChoiceResponse.getChoiceInfo().getChoice().size(); i++) { + var choice = createGameChoiceResponse.getChoiceInfo().getChoice().get(i); + + ChoiceMongo choiceMongo = ChoiceMongo.builder() + .detail(choice.getDetail()) + .stats(statInfo.get(i)) + .probability(null) + .resultType(ChoiceResultType.nextResultType()) + .build(); + + choiceList.add(choiceMongo); + } + + ChoiceInfoMongo choiceInfoMongo = ChoiceInfoMongo.builder() + .eventType(fastApiRequest.getEventType()) + .story(createGameChoiceResponse.getChoiceInfo().getStory()) + .choice(choiceList) + .build(); + + gameSessionMongo.setChoiceInfo(choiceInfoMongo); + + HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); + + if (currentEventStage > 0) { + switch (currentEventStage) { + case 1: + historyInfoMongo.setEpilogue1Title(createGameChoiceResponse.getChoiceInfo().getTitle()); + historyInfoMongo.setEpilogue1Content(createGameChoiceResponse.getChoiceInfo().getStory()); + break; + case 3: + historyInfoMongo.setEpilogue2Title(createGameChoiceResponse.getChoiceInfo().getTitle()); + historyInfoMongo.setEpilogue2Content(createGameChoiceResponse.getChoiceInfo().getStory()); + break; + case 5: + historyInfoMongo.setEpilogue3Title(createGameChoiceResponse.getChoiceInfo().getTitle()); + historyInfoMongo.setEpilogue3Content(createGameChoiceResponse.getChoiceInfo().getStory()); + break; + } + } + + gameSessionMongo.setHistoryInfo(historyInfoMongo); + gameSessionMongoRepository.save(gameSessionMongo); - return response; + return gameSessionMongo; } } From 6b5323b6260063f8c4ad6b99c97ca97c434281b3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 17:35:14 +0900 Subject: [PATCH 324/527] =?UTF-8?q?=C3=ACwip:=20working=20late?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/scriptopia/demo/service/GameSessionService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d98d5b0b..9245caf7 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -24,7 +24,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -430,6 +429,9 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); + /** + * 이 부분 저장하는 것은 해당 DONE 이 나올 때 요약을 사용해야 함 **여기가 아님** + */ if (currentEventStage > 0) { switch (currentEventStage) { case 1: From de780d15dc49262bf3c261f92eae3da3eeac39fb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 17:52:25 +0900 Subject: [PATCH 325/527] feat: create error code E_400_INVALID_NPC_RANK --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 181c04b4..5eb663f8 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -37,6 +37,7 @@ public enum ErrorCode { E_400_UNSUPPORTED_PROVIDER("E400024", "지원하지 않는 소셜 로그인 공급자입니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_NPC_RANK("E400100", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized @@ -72,7 +73,6 @@ public enum ErrorCode { - //409 Conflict E_409_EMAIL_TAKEN("E409001", "이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT), E_409_NICKNAME_TAKEN("E409002", "이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT), From f0f163261998ec40caa01f6a6d403fdaa08078bd Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 17:55:33 +0900 Subject: [PATCH 326/527] feat: create error code E_400_INVALID_NPC_RANK --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 5eb663f8..d82d76a5 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -37,7 +37,7 @@ public enum ErrorCode { E_400_UNSUPPORTED_PROVIDER("E400024", "지원하지 않는 소셜 로그인 공급자입니다.", HttpStatus.BAD_REQUEST), E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_NPC_RANK("E400100", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized From 153e5701d202dccfad412a1d7e12a1106e3813f8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 18:35:50 +0900 Subject: [PATCH 327/527] refactor: add npc stat to gameChoice logic --- .../demo/service/GameSessionService.java | 34 +++++---- .../demo/utils/GameBalanceUtil.java | 75 ++++++++++++++++++- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 9245caf7..f21baabc 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -3,6 +3,7 @@ import com.mongodb.client.MongoClient; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; +import com.scriptopia.demo.utils.GameBalanceUtil; import com.scriptopia.demo.utils.InitGameData; import com.scriptopia.demo.domain.*; import com.scriptopia.demo.domain.mongo.*; @@ -341,7 +342,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { ChoiceEventType currentEventType = ChoiceEventType.getChoiceEventType(); fastApiRequest.setEventType(currentEventType); - int currentNpcRank = 4; + int currentNpcRank = 0; if (currentEventType == ChoiceEventType.LIVING) { int currentChapter = progress / (stage.size() / 3 + 1) + 1; currentNpcRank = NpcGrade.getNpcNumberByRandom(currentChapter); @@ -390,20 +391,25 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { gameSessionMongo.setUpdatedAt(LocalDateTime.now()); gameSessionMongo.setBackground(createGameChoiceResponse.getChoiceInfo().getStory()); - gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); - - - NpcInfoMongo npcInfoMongo = NpcInfoMongo.builder() - .rank(currentNpcRank) - .name(createGameChoiceResponse.getNpcInfo().getName()) - .trait(createGameChoiceResponse.getNpcInfo().getTrait()) - .NpcWeaponName(createGameChoiceResponse.getNpcInfo().getNpcWeaponName()) - .NpcWeaponDescription(createGameChoiceResponse.getNpcInfo().getNpcWeaponDescription()) - .build(); - - gameSessionMongo.setNpcInfo(npcInfoMongo); - + gameSessionMongo.setProgress(gameSessionMongo.getProgress()); + + + if (currentNpcRank > 0){ + int[] npcStat = GameBalanceUtil.getNpcStatsByRank(currentNpcRank); + NpcInfoMongo npcInfoMongo = NpcInfoMongo.builder() + .rank(currentNpcRank) + .name(createGameChoiceResponse.getNpcInfo().getName()) + .trait(createGameChoiceResponse.getNpcInfo().getTrait()) + .NpcWeaponName(createGameChoiceResponse.getNpcInfo().getNpcWeaponName()) + .NpcWeaponDescription(createGameChoiceResponse.getNpcInfo().getNpcWeaponDescription()) + .strength(npcStat[0]) + .agility(npcStat[1]) + .intelligence(npcStat[2]) + .luck(npcStat[3]) + .build(); + gameSessionMongo.setNpcInfo(npcInfoMongo); + } List choiceList = new ArrayList<>(); for (int i = 0; i < createGameChoiceResponse.getChoiceInfo().getChoice().size(); i++) { diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 7567087c..c62fd3f1 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -1,21 +1,44 @@ package com.scriptopia.demo.utils; import com.scriptopia.demo.domain.Grade; -import com.scriptopia.demo.repository.EffectGradeDefRepository; -import com.scriptopia.demo.repository.ItemGradeDefRepository; +import com.scriptopia.demo.domain.Stat; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.List; @Component @RequiredArgsConstructor public class GameBalanceUtil { - private final ItemGradeDefRepository itemGradeDefRepository; - private final EffectGradeDefRepository effectGradeDefRepository; static SecureRandom secureRandom = new SecureRandom(); + static final int PLAYER_BASE_STAT = 5; + + /** + * NPC 기본 스탯 테이블 + * rank → [STR, AGI, INT, LUK] + */ + private static final int[][] NPC_BASE_STATS = { + {3, 3, 3, 3}, // rank 0 (dummy, 사용 안 함) + {5, 4, 3, 2}, // rank 1 : C 하급 + {7, 6, 4, 3}, // rank 2 : C 일반 + {9, 7, 5, 4}, // rank 3 : C 상급 + {12, 9, 6, 5}, // rank 4 : U 중급 + {15, 12, 8, 6}, // rank 5 : U 중급+ + {18, 14, 10, 7},// rank 6 : R 정예 시작 + {22, 17, 12, 9},// rank 7 : R 강한 정예 + {28, 20, 15, 11},// rank 8 : E 보스 + {35, 25, 18, 14},// rank 9 : E 보스급+ + {50, 35, 25, 20},// rank 10 : E+ 대륙 위협 + {70, 50, 35, 25},// rank 11 : L 국가급 + {100, 70, 50, 40}// rank 12 : L+ 초월급 + }; + + /** * @param grade @@ -71,4 +94,48 @@ public static Long getRandomItemPriceByGrade(List effectGradeList) { return effectPrice; } + + /** + * 플레이어 기본 스탯 초기화 + * @param playerStat (플레이어가 선택한 주 스탯) + * @return (0: STR, 1: AGI, 2: INT, 3: LUK) + */ + public static int[] getInitialPlayerStats(Stat playerStat) { + int mainStat = secureRandom.nextInt(3); // 0 ~ 2 + int subStat = secureRandom.nextInt(3) - 2; // -2 ~ 0 + + int[] stats = new int[4]; + stats[0] = (playerStat.equals(Stat.STRENGTH)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + stats[1] = (playerStat.equals(Stat.AGILITY)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + stats[2] = (playerStat.equals(Stat.INTELLIGENCE)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + stats[3] = (playerStat.equals(Stat.LUCK)) ? PLAYER_BASE_STAT + mainStat : PLAYER_BASE_STAT + subStat; + + return stats; + } + + + /** + * NPC 스탯 생성 (랭크 기반) + * @param rank (1 ~ 12) + * @return (0: STR, 1: AGI, 2: INT, 3: LUK) + */ + public static int[] getNpcStatsByRank(int rank) { + if (rank < 1 || rank >= NPC_BASE_STATS.length) { + throw new CustomException(ErrorCode.E_400_INVALID_NPC_RANK); + } + + int[] base = NPC_BASE_STATS[rank]; + int[] npcStats = new int[4]; + + // 약간의 랜덤 편차 추가 (±10%) + for (int i = 0; i < 4; i++) { + int variance = (int) Math.round(base[i] * (secureRandom.nextDouble() * 0.2 - 0.1)); + npcStats[i] = base[i] + variance; + } + + return npcStats; + } + + + } From 2e20c6dc4779ade967a4cfb895c3c557b4455c06 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 18:51:47 +0900 Subject: [PATCH 328/527] feat: craete getChoiceProbability --- .../com/scriptopia/demo/utils/GameBalanceUtil.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index c62fd3f1..7b8b62a2 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -136,6 +136,17 @@ public static int[] getNpcStatsByRank(int rank) { return npcStats; } + public static boolean getChoiceProbability(int statValue) { + double baseRate = 40.0; // 기본 확률 40% + double minRate = 30.0; // 최소 30% + double maxRate = 80.0; // 최대 80% + double statBonus = 0.8 * statValue; // 스탯 1당 +0.8% + + double finalRate = baseRate + statBonus; + finalRate = Math.max(minRate, Math.min(finalRate, maxRate)); + + return secureRandom.nextDouble() * 100 < finalRate; + } } From e28ee6949f568cc112c379333a520f05ea897857 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 19:03:42 +0900 Subject: [PATCH 329/527] feat: create FastApiClient and FastApiEndpoint --- .../demo/config/fastapi/FastApiClient.java | 29 +++++++++++++++++++ .../demo/config/fastapi/FastApiEndpoint.java | 17 +++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java create mode 100644 src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java new file mode 100644 index 00000000..defc1aa6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java @@ -0,0 +1,29 @@ +package com.scriptopia.demo.config.fastapi; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class FastApiClient { + + private final WebClient webClient; + + public FastApiClient() { + this.webClient = WebClient.builder() + .baseUrl("http://localhost:8000") // 환경별로 application.yml에 두는 것이 좋음 + .build(); + } + + public WebClient.RequestBodySpec post(FastApiEndpoint endpoint) { + return webClient.post() + .uri(endpoint.getPath()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON); + } + + public WebClient getWebClient() { + return webClient; + } + +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java new file mode 100644 index 00000000..ea7d2968 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.config.fastapi; + +public enum FastApiEndpoint { + INIT("/games/init"), + CHOICE("/games/choice"), + BATTLE("/games/battle"); + + private final String path; + + FastApiEndpoint(String path) { + this.path = path; + } + + public String getPath() { + return path; + } +} \ No newline at end of file From 529ce04b5a928ca43e08f678f510950880f5c770 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 19:09:00 +0900 Subject: [PATCH 330/527] feat: create FastApiService --- .../demo/config/fastapi/FastApiClient.java | 27 +++-------- .../demo/service/FastApiService.java | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/service/FastApiService.java diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java index defc1aa6..64a6ae68 100644 --- a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiClient.java @@ -1,29 +1,16 @@ package com.scriptopia.demo.config.fastapi; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; -@Component +@Configuration public class FastApiClient { - private final WebClient webClient; - - public FastApiClient() { - this.webClient = WebClient.builder() - .baseUrl("http://localhost:8000") // 환경별로 application.yml에 두는 것이 좋음 + @Bean + public WebClient fastApiWebClient() { + return WebClient.builder() + .baseUrl("http://localhost:8000") .build(); } - - public WebClient.RequestBodySpec post(FastApiEndpoint endpoint) { - return webClient.post() - .uri(endpoint.getPath()) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON); - } - - public WebClient getWebClient() { - return webClient; - } - } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java new file mode 100644 index 00000000..3cbecabc --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -0,0 +1,47 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.config.fastapi.FastApiEndpoint; +import com.scriptopia.demo.dto.gamesession.CreateGameChoiceRequest; +import com.scriptopia.demo.dto.gamesession.CreateGameChoiceResponse; +import com.scriptopia.demo.dto.gamesession.CreateGameRequest; +import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@RequiredArgsConstructor +public class FastApiService { + + private final WebClient fastApiWebClient; + + // 게임 초기화 + public ExternalGameResponse initGame(CreateGameRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.INIT.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(ExternalGameResponse.class) + .block(); + } + + // 선택지 생성 + public CreateGameChoiceResponse makeChoice(CreateGameChoiceRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.CHOICE.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(CreateGameChoiceResponse.class) + .block(); + } + + // 전투 호출 (확장용) + public Object battle(Object request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.BATTLE.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(Object.class) + .block(); + } +} From 87f24e09e6ae5922b65eeb4f6d7a16060f6310a5 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 19:12:19 +0900 Subject: [PATCH 331/527] refactor: web soket to Fast API single-ton --- .../demo/service/GameSessionService.java | 43 +++++-------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f21baabc..dd3e5fdf 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.service; import com.mongodb.client.MongoClient; +import com.scriptopia.demo.config.fastapi.FastApiClient; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; import com.scriptopia.demo.utils.GameBalanceUtil; @@ -17,7 +18,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.*; -import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; @@ -36,8 +36,9 @@ public class GameSessionService { private final UserRepository userRepository; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; - private final MongoClient mongo; - private final WebInvocationPrivilegeEvaluator privilegeEvaluator; + private final FastApiClient fastApiClient; + private final FastApiService fastApiService; + public boolean duplcatedGameSession(Long userId) { User user = userRepository.findById(userId) @@ -120,22 +121,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { ); - // WebClient 인스턴스 생성 - WebClient client = WebClient.builder() - .baseUrl("http://localhost:8000") - .build(); + ExternalGameResponse externalGame = fastApiService.initGame(createGameRequest); - // FastAPI 호출(테스트용 추후 변경 가능) - ExternalGameResponse externalGame = client.post() - .uri("/games/init") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .bodyValue(createGameRequest) // - .retrieve() - .bodyToMono(ExternalGameResponse.class) - .block(); // - - // 응답 검증 if (externalGame == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } @@ -368,22 +355,8 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { fastApiRequest.setItemInfo(itemInfoList); - // WebClient 인스턴스 생성 - WebClient client = WebClient.builder() - .baseUrl("http://localhost:8000") - .build(); + CreateGameChoiceResponse createGameChoiceResponse = fastApiService.makeChoice(fastApiRequest); - // FastAPI 호출(테스트용 추후 변경 가능) - CreateGameChoiceResponse createGameChoiceResponse = client.post() - .uri("/games/choice") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .bodyValue(fastApiRequest) // - .retrieve() - .bodyToMono(CreateGameChoiceResponse.class) - .block(); // - - // 응답 검증 if (createGameChoiceResponse == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } @@ -411,6 +384,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { gameSessionMongo.setNpcInfo(npcInfoMongo); } + List choiceList = new ArrayList<>(); for (int i = 0; i < createGameChoiceResponse.getChoiceInfo().getChoice().size(); i++) { var choice = createGameChoiceResponse.getChoiceInfo().getChoice().get(i); @@ -460,4 +434,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { return gameSessionMongo; } + + + } From 9e1bd4fb83dd7c4f5e75e0cec836c445dfc42275 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 19:17:14 +0900 Subject: [PATCH 332/527] feat: create CreateGameBattleRequest --- .../gamesession/CreateGameBattleRequest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java new file mode 100644 index 00000000..f62fd2b2 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java @@ -0,0 +1,48 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateGameBattleRequest { + + private int turnCount; + private String worldView; + private String location; + + private Player player; + private Npc npc; + + private List> hpLog; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Player { + private String name; + private String trait; + private int dmg; + private String weapon; + private String armor; + private String artifact; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Npc { + private String name; + private String trait; + private int dmg; + private String weapon; + } +} From 42a25d91a60baabe13ec62d394a5bbee1254b8e2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 19:26:43 +0900 Subject: [PATCH 333/527] feat: create return item isEquipped --- .../java/com/scriptopia/demo/domain/mongo/InventoryMongo.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java index 794c33d4..be86db4b 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java @@ -17,4 +17,8 @@ public class InventoryMongo { private LocalDateTime acquiredAt; private Boolean equipped; private String source; + + public boolean isEquipped() { + return this.equipped; + } } From da1bc53011c51f90627090d1b0c084a5a97ed5d4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 20:40:42 +0900 Subject: [PATCH 334/527] refactor: change dto structure --- .../gamesession/CreateGameBattleRequest.java | 46 +++++++++++-------- .../gamesession/CreateGameBattleResponse.java | 4 ++ 2 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java index f62fd2b2..e09dc534 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java @@ -17,32 +17,38 @@ public class CreateGameBattleRequest { private String worldView; private String location; - private Player player; - private Npc npc; - + private String playerName; + private String playerTrait; + private int playerDmg; + private Item playerWeapon; + private Item playerArmor; + private Item playerArtifact; + + private String npcName; + private String npcTrait; + private int npcDmg; + private String npcWeapon; + private String npcWeaponDescription; + + private int battleResult; private List> hpLog; @Data @Builder @NoArgsConstructor @AllArgsConstructor - public static class Player { - private String name; - private String trait; - private int dmg; - private String weapon; - private String armor; - private String artifact; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class Npc { + public static class Item { private String name; - private String trait; - private int dmg; - private String weapon; + private String description; + private List effects; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ItemEffect { + private String name; + private String description; + } } } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java new file mode 100644 index 00000000..49b2c8bb --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession; + +public class CreateGameBattleResponse { +} From cac2837a420a8534ca33cc3ce941506119741ce3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 20:41:00 +0900 Subject: [PATCH 335/527] feat: create CreateGameBattleResponse dto --- .../gamesession/CreateGameBattleResponse.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java index 49b2c8bb..0f508e22 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java @@ -1,4 +1,21 @@ package com.scriptopia.demo.dto.gamesession; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.util.List; + public class CreateGameBattleResponse { + private BattleInfoDto battleInfo; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BattleInfoDto { + private List turnInfo; // 턴별 전투 로그 + private String reCap; // 전투 요약 + } } From 2d9b91aea70daaab83ef77ef25038a181a2c541f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 20:44:55 +0900 Subject: [PATCH 336/527] refactor: update return type to battle --- .../java/com/scriptopia/demo/service/FastApiService.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index 3cbecabc..60fb9c14 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -1,10 +1,7 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.config.fastapi.FastApiEndpoint; -import com.scriptopia.demo.dto.gamesession.CreateGameChoiceRequest; -import com.scriptopia.demo.dto.gamesession.CreateGameChoiceResponse; -import com.scriptopia.demo.dto.gamesession.CreateGameRequest; -import com.scriptopia.demo.dto.gamesession.ExternalGameResponse; +import com.scriptopia.demo.dto.gamesession.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @@ -36,12 +33,12 @@ public CreateGameChoiceResponse makeChoice(CreateGameChoiceRequest request) { } // 전투 호출 (확장용) - public Object battle(Object request) { + public CreateGameBattleResponse battle(Object request) { return fastApiWebClient.post() .uri(FastApiEndpoint.BATTLE.getPath()) .bodyValue(request) .retrieve() - .bodyToMono(Object.class) + .bodyToMono(CreateGameBattleResponse.class) .block(); } } From 66a1187380b6a60ba4e68fc33cb1e44cab81306c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 20:46:01 +0900 Subject: [PATCH 337/527] refactor: change logic to create battleLog --- .../demo/service/GameSessionService.java | 94 +++++++++++- .../demo/utils/GameBalanceUtil.java | 136 ++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index dd3e5fdf..a7a99cee 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.service; -import com.mongodb.client.MongoClient; import com.scriptopia.demo.config.fastapi.FastApiClient; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; @@ -20,7 +19,6 @@ import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; import java.util.ArrayList; @@ -436,5 +434,97 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { } + @Transactional + public CreateGameBattleResponse mapToCreateGameBattleRequest(Stat plyerStat, Long userId) { + if (!gameSessionRepository.existsByUserId(userId)) { + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + + GameSessionMongo gameSession = gameSessionMongoRepository.findById(userId.toString()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + // 장착된 플레이어 아이템 + List equippedItems = new ArrayList<>(); + for (InventoryMongo inv : gameSession.getInventory()) { + if (inv.isEquipped()) { + ItemDefMongo item = itemDefMongoRepository.findById(inv.getItemDefId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + equippedItems.add(item); + } + } + + // weapon, armor, artifact 분류 + ItemDefMongo weapon = null; + ItemDefMongo armor = null; + ItemDefMongo artifact = null; + + for (ItemDefMongo item : equippedItems) { + switch (item.getCategory()) { + case ItemType.WEAPON -> weapon = item; + case ItemType.ARMOR -> armor = item; + case ItemType.ARTIFACT -> artifact = item; + } + } + int playerDmg = (weapon == null) ? 22 : weapon.getBaseStat(); + int playerHp = (weapon == null) ? gameSession.getPlayerInfo().getHealthPoint() : armor.getBaseStat(); + + int npcRank = gameSession.getNpcInfo().getRank(); + int playerCombatPoint = GameBalanceUtil.getBattlePlayerCombatPoint(gameSession.getPlayerInfo(), weapon, artifact); + int npcCombatPoint = GameBalanceUtil.getNpcCombatPoint(npcRank); + int playerWin = GameBalanceUtil.simulateBattle(playerCombatPoint,npcCombatPoint); + + + List> BattleLog = GameBalanceUtil.getBattleLog(playerWin, playerDmg, playerHp, playerCombatPoint, npcRank); + + + // Builder 패턴으로 CreateGameBattleRequest 구성 + CreateGameBattleRequest fastApiRequest = CreateGameBattleRequest.builder() + .turnCount(BattleLog.size()) + .worldView(gameSession.getHistoryInfo().getWorldView()) + .location(gameSession.getLocation()) + .playerName(gameSession.getPlayerInfo().getName()) + .playerTrait(gameSession.getPlayerInfo().getTrait()) + .playerDmg(gameSession.getPlayerInfo().getStrength()) + .playerWeapon(weapon != null ? mapToItemEffect(weapon) : null) + .playerArmor(armor != null ? mapToItemEffect(armor) : null) + .playerArtifact(artifact != null ? mapToItemEffect(artifact) : null) + .npcName(gameSession.getNpcInfo().getName()) + .npcTrait(gameSession.getNpcInfo().getTrait()) + .npcDmg(gameSession.getNpcInfo().getStrength()) + .npcWeapon(gameSession.getNpcInfo().getNpcWeaponName()) + .npcWeaponDescription(gameSession.getNpcInfo().getNpcWeaponDescription()) + .battleResult(playerWin) + .hpLog( BattleLog ) + .build(); + + + + + CreateGameBattleResponse fastApiResponse = fastApiService.battle(fastApiRequest); + + if (fastApiResponse == null) { + throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); + } + + return fastApiResponse; + + } + + // ItemDefMongo -> CreateGameBattleRequest.Item 변환 + private CreateGameBattleRequest.Item mapToItemEffect(ItemDefMongo item) { + List effects = item.getItemEffect().stream() + .map(e -> CreateGameBattleRequest.Item.ItemEffect.builder() + .name(e.getItemEffectName()) + .description(e.getItemEffectDescription()) + .build()) + .toList(); + + return CreateGameBattleRequest.Item.builder() + .name(item.getName()) + .description(item.getDescription()) + .effects(effects) + .build(); + } } diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 7b8b62a2..babda347 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -2,6 +2,8 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.Stat; +import com.scriptopia.demo.domain.mongo.ItemDefMongo; +import com.scriptopia.demo.domain.mongo.PlayerInfoMongo; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -10,6 +12,8 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; +import java.util.NavigableMap; +import java.util.TreeMap; @Component @RequiredArgsConstructor @@ -38,6 +42,39 @@ public class GameBalanceUtil { {100, 70, 50, 40}// rank 12 : L+ 초월급 }; + // NPC 등급별 예상 HP와 공격력 (HealthPoint, CombatPoint) + public static final int[][] NPC_BATTLE_STATS = { + {}, // index 0 dummy + {70, 18}, // rank 1: {HealthPoint, CombatPoint} + {105, 26}, // rank 2 + {140, 35}, // rank 3 + {152, 38}, // rank 4 + {188, 47}, // rank 5 + {204, 51}, // rank 6 + {248, 62}, // rank 7 + {268, 67}, // rank 8 + {320, 80}, // rank 9 + {344, 86}, // rank 10 + {707, 101}, // rank 11 + {963, 107} // rank 12 + }; + + + + private static final NavigableMap STAT_MULTIPLIERS = new TreeMap<>() {{ + put(5, 1.1); + put(10, 1.2); + put(15, 1.3); + put(20, 1.4); + put(25, 1.5); + put(30, 1.6); + put(35, 1.7); + put(40, 1.8); + put(45, 1.9); + put(Integer.MAX_VALUE, 2.0); // 46 이상 + }}; + + /** @@ -148,5 +185,104 @@ public static boolean getChoiceProbability(int statValue) { return secureRandom.nextDouble() * 100 < finalRate; } + /** + * @param player + * @param weapon + * @param artifact + * @return PlayerCombatPoint + */ + public static int getBattlePlayerCombatPoint(PlayerInfoMongo player, ItemDefMongo weapon, ItemDefMongo artifact) { + int baseCombatPoint = 22; // 맨손일 때 + + if (weapon == null) { + return baseCombatPoint; + } + + // 무기 메인 스탯 결정 + Stat mainStat = weapon.getMainStat(); + int playerStatValue = switch (mainStat) { + case STRENGTH -> player.getStrength(); + case AGILITY -> player.getAgility(); + case INTELLIGENCE -> player.getIntelligence(); + case LUCK -> player.getLuck(); + }; + + double multiplier = STAT_MULTIPLIERS.ceilingEntry(playerStatValue).getValue(); + + // 최종 전투력 계산 + int weaponStat = weapon.getBaseStat(); + int combatPoint = (int) (weaponStat * multiplier); + + return combatPoint; + } + + + public static int simulateBattle(int playerCombatPoint, int npcCombatPoint){ + int maxNum = playerCombatPoint + npcCombatPoint; + int num = secureRandom.nextInt(maxNum) + 1; + + return playerCombatPoint >= num ? 1 : 0 ; + } + + public static List> getBattleLog(int playerWin, int playerDmg, int playerHp, int npcDmg, int npcRank) { + List> hpLog = new ArrayList<>(); + + int npcHp = getNpcHealthPoint(npcRank); + int maxTurns = 10; + boolean playerVictory = playerWin == 1; + + int prevPlayerHp = playerHp; + int prevNpcHp = npcHp; + + for (int turn = 0; turn < maxTurns; turn++) { + if (playerHp <= 0 || npcHp <= 0) break; + + // 랜덤 데미지 ±10% + int actualPlayerDmg = (int) Math.round(playerDmg * (0.9 + secureRandom.nextDouble() * 0.2)); + int actualNpcDmg = (int) Math.round(npcDmg * (0.9 + secureRandom.nextDouble() * 0.2)); + + npcHp -= actualPlayerDmg; + playerHp -= actualNpcDmg; + + // 승리 쪽이 턴 중 0 이하가 되면 이전 HP 범위에서 랜덤 회복 + if (playerVictory && playerHp <= 0) { + playerHp = secureRandom.nextInt(prevPlayerHp) + 1; + } else if (!playerVictory && npcHp <= 0) { + npcHp = secureRandom.nextInt(prevNpcHp) + 1; + } + + hpLog.add(List.of(Math.max(playerHp, 0), Math.max(npcHp, 0))); + + prevPlayerHp = playerHp; + prevNpcHp = npcHp; + } + + // 마지막 턴에 승리 쪽 HP 최소 1로 보정 + if (playerVictory) { + npcHp = 0; + playerHp = Math.max(playerHp, 1); + } else { + playerHp = 0; + npcHp = Math.max(npcHp, 1); + } + hpLog.add(List.of(playerHp, npcHp)); + + return hpLog; + } + + + public static int getNpcCombatPoint(int npcRank) { + int base = NPC_BATTLE_STATS[npcRank][1]; + + double variance = 0.9 + secureRandom.nextDouble() * 0.2; // 0.9 ~ 1.1 + return (int) Math.round(base * variance); + } + + public static int getNpcHealthPoint(int npcRank) { + int base = NPC_BATTLE_STATS[npcRank][0]; + + double variance = 0.9 + secureRandom.nextDouble() * 0.2; // 0.9 ~ 1.1 + return (int) Math.round(base * variance); + } } From f7ddac82321c150347c6d5f7a2b8c6ae7f3acc45 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 22:20:57 +0900 Subject: [PATCH 338/527] refactor: change DataLoaderConfig to effect_grade_def weight --- .../demo/config/DataLoaderConfig.java | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java index fd7151a3..3e5f2876 100644 --- a/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java +++ b/src/main/java/com/scriptopia/demo/config/DataLoaderConfig.java @@ -14,7 +14,7 @@ public class DataLoaderConfig { private final EffectGradeDefRepository effectGradeDefRepository; - private final ItemGradeDefRepository itemGradeDefRepository; // 추가 + private final ItemGradeDefRepository itemGradeDefRepository; @Bean public ApplicationRunner dataLoader() { @@ -28,15 +28,33 @@ public ApplicationRunner dataLoader() { EffectProbability.LEGENDARY, 100L ); + Map effectAtkMultiplierMap = Map.of( + EffectProbability.COMMON, 0.10, // C + EffectProbability.UNCOMMON, 0.15, // U + EffectProbability.RARE, 0.20, // R + EffectProbability.EPIC, 0.25, // E + EffectProbability.LEGENDARY, 0.30 // L + ); + for (EffectProbability prob : EffectProbability.values()) { if (prob == null) continue; - if (effectGradeDefRepository.findByEffectProbability(prob).isEmpty()) { - EffectGradeDef def = new EffectGradeDef(); - def.setEffectProbability(prob); - def.setPrice(effectPriceMap.get(prob)); - def.setWeight(1.0); - effectGradeDefRepository.save(def); - } + + effectGradeDefRepository.findByEffectProbability(prob).ifPresentOrElse( + def -> { + // 이미 있으면 업데이트 + def.setPrice(effectPriceMap.get(prob)); + def.setWeight(effectAtkMultiplierMap.get(prob)); + effectGradeDefRepository.save(def); + }, + () -> { + // 없으면 새로 생성 + EffectGradeDef def = new EffectGradeDef(); + def.setEffectProbability(prob); + def.setPrice(effectPriceMap.get(prob)); + def.setWeight(effectAtkMultiplierMap.get(prob)); + effectGradeDefRepository.save(def); + } + ); } // ItemGradeDef 초기화 @@ -50,13 +68,21 @@ public ApplicationRunner dataLoader() { for (Grade grade : Grade.values()) { if (grade == null) continue; - if (itemGradeDefRepository.findByGrade(grade).isEmpty()) { - ItemGradeDef def = new ItemGradeDef(); - def.setGrade(grade); - def.setPrice(itemGradePriceMap.get(grade)); - def.setWeight(1.0); - itemGradeDefRepository.save(def); - } + + itemGradeDefRepository.findByGrade(grade).ifPresentOrElse( + def -> { + def.setPrice(itemGradePriceMap.get(grade)); + def.setWeight(1.0); + itemGradeDefRepository.save(def); + }, + () -> { + ItemGradeDef def = new ItemGradeDef(); + def.setGrade(grade); + def.setPrice(itemGradePriceMap.get(grade)); + def.setWeight(1.0); + itemGradeDefRepository.save(def); + } + ); } }; } From ec137302d3dbb9ecd690855a33ca3ccde428b589 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 9 Sep 2025 22:29:18 +0900 Subject: [PATCH 339/527] refactor: change to player-combatPoint calurate methode and battle-log logic --- .../controller/GameSessionController.java | 17 +++- .../gamesession/CreateGameBattleRequest.java | 2 +- .../gamesession/CreateGameBattleResponse.java | 8 +- .../demo/service/FastApiService.java | 2 +- .../demo/service/GameSessionService.java | 53 ++++++----- .../demo/utils/GameBalanceUtil.java | 95 +++++++++++-------- 6 files changed, 105 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 7b27000b..29d6c51b 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -50,8 +50,8 @@ public ResponseEntity startNewGame( /** * 테스트 중 */ - @PostMapping("/test") - public ResponseEntity testGame( + @PostMapping("/testC") + public ResponseEntity testCoGame( Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); @@ -60,6 +60,19 @@ public ResponseEntity testGame( return ResponseEntity.ok(response); } + /** + * 테스트 중 + */ + @PostMapping("/test") + public ResponseEntity testGame( + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + CreateGameBattleResponse response = gameSessionService.mapToCreateGameBattleRequest(userId); + return ResponseEntity.ok(response); + } + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java index e09dc534..d3d62c35 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleRequest.java @@ -30,7 +30,7 @@ public class CreateGameBattleRequest { private String npcWeapon; private String npcWeaponDescription; - private int battleResult; + private Integer battleResult; private List> hpLog; @Data diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java index 0f508e22..fe962061 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameBattleResponse.java @@ -1,12 +1,12 @@ package com.scriptopia.demo.dto.gamesession; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; +import lombok.*; import java.util.List; +@Data +@NoArgsConstructor +@AllArgsConstructor public class CreateGameBattleResponse { private BattleInfoDto battleInfo; diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index 60fb9c14..d57590c9 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -33,7 +33,7 @@ public CreateGameChoiceResponse makeChoice(CreateGameChoiceRequest request) { } // 전투 호출 (확장용) - public CreateGameBattleResponse battle(Object request) { + public CreateGameBattleResponse battle(CreateGameBattleRequest request) { return fastApiWebClient.post() .uri(FastApiEndpoint.BATTLE.getPath()) .bodyValue(request) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index a7a99cee..fab9ad1e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -34,7 +34,6 @@ public class GameSessionService { private final UserRepository userRepository; private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; - private final FastApiClient fastApiClient; private final FastApiService fastApiService; @@ -435,18 +434,24 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { @Transactional - public CreateGameBattleResponse mapToCreateGameBattleRequest(Stat plyerStat, Long userId) { + public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } - GameSessionMongo gameSession = gameSessionMongoRepository.findById(userId.toString()) + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + String gameId = gameSession.getMongoId(); + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + // 장착된 플레이어 아이템 List equippedItems = new ArrayList<>(); - for (InventoryMongo inv : gameSession.getInventory()) { + for (InventoryMongo inv : gameSessionMongo.getInventory()) { if (inv.isEquipped()) { ItemDefMongo item = itemDefMongoRepository.findById(inv.getItemDefId()) .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); @@ -468,41 +473,43 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Stat plyerStat, Lon } int playerDmg = (weapon == null) ? 22 : weapon.getBaseStat(); - int playerHp = (weapon == null) ? gameSession.getPlayerInfo().getHealthPoint() : armor.getBaseStat(); + int playerHp = (armor == null) ? gameSessionMongo.getPlayerInfo().getHealthPoint() : armor.getBaseStat(); - int npcRank = gameSession.getNpcInfo().getRank(); - int playerCombatPoint = GameBalanceUtil.getBattlePlayerCombatPoint(gameSession.getPlayerInfo(), weapon, artifact); + int npcRank = gameSessionMongo.getNpcInfo().getRank(); + int playerWeaponDmg = GameBalanceUtil.getPlayerWeaponDmg(gameSessionMongo.getPlayerInfo(), weapon); + int playerCombatPoint = GameBalanceUtil.getBattlePlayerCombatPoint(gameSessionMongo.getPlayerInfo(), weapon, effectGradeDefRepository); int npcCombatPoint = GameBalanceUtil.getNpcCombatPoint(npcRank); - int playerWin = GameBalanceUtil.simulateBattle(playerCombatPoint,npcCombatPoint); + Integer playerWin = GameBalanceUtil.simulateBattle(playerCombatPoint,npcCombatPoint); - List> BattleLog = GameBalanceUtil.getBattleLog(playerWin, playerDmg, playerHp, playerCombatPoint, npcRank); + List> battleLog = GameBalanceUtil.getBattleLog(playerWin, playerDmg, playerHp, playerCombatPoint, npcRank); // Builder 패턴으로 CreateGameBattleRequest 구성 CreateGameBattleRequest fastApiRequest = CreateGameBattleRequest.builder() - .turnCount(BattleLog.size()) - .worldView(gameSession.getHistoryInfo().getWorldView()) - .location(gameSession.getLocation()) - .playerName(gameSession.getPlayerInfo().getName()) - .playerTrait(gameSession.getPlayerInfo().getTrait()) - .playerDmg(gameSession.getPlayerInfo().getStrength()) + .turnCount(battleLog.size()) + .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) + .location(gameSessionMongo.getLocation()) + .playerName(gameSessionMongo.getPlayerInfo().getName()) + .playerTrait(gameSessionMongo.getPlayerInfo().getTrait()) + .playerDmg(playerWeaponDmg) .playerWeapon(weapon != null ? mapToItemEffect(weapon) : null) .playerArmor(armor != null ? mapToItemEffect(armor) : null) .playerArtifact(artifact != null ? mapToItemEffect(artifact) : null) - .npcName(gameSession.getNpcInfo().getName()) - .npcTrait(gameSession.getNpcInfo().getTrait()) - .npcDmg(gameSession.getNpcInfo().getStrength()) - .npcWeapon(gameSession.getNpcInfo().getNpcWeaponName()) - .npcWeaponDescription(gameSession.getNpcInfo().getNpcWeaponDescription()) + .npcName(gameSessionMongo.getNpcInfo().getName()) + .npcTrait(gameSessionMongo.getNpcInfo().getTrait()) + .npcDmg(gameSessionMongo.getNpcInfo().getStrength()) + .npcWeapon(gameSessionMongo.getNpcInfo().getNpcWeaponName()) + .npcWeaponDescription(gameSessionMongo.getNpcInfo().getNpcWeaponDescription()) .battleResult(playerWin) - .hpLog( BattleLog ) + .hpLog( battleLog ) .build(); - - CreateGameBattleResponse fastApiResponse = fastApiService.battle(fastApiRequest); + System.out.println("fastApiRequest = " + fastApiRequest); + System.out.println("전투로그 = " + battleLog + " 턴 = " + battleLog.size()); + if (fastApiResponse == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index babda347..c7a3392a 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -1,19 +1,19 @@ package com.scriptopia.demo.utils; +import com.scriptopia.demo.domain.EffectProbability; import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.Stat; import com.scriptopia.demo.domain.mongo.ItemDefMongo; +import com.scriptopia.demo.domain.mongo.ItemEffectMongo; import com.scriptopia.demo.domain.mongo.PlayerInfoMongo; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.EffectGradeDefRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.List; -import java.util.NavigableMap; -import java.util.TreeMap; +import java.util.*; @Component @RequiredArgsConstructor @@ -185,13 +185,7 @@ public static boolean getChoiceProbability(int statValue) { return secureRandom.nextDouble() * 100 < finalRate; } - /** - * @param player - * @param weapon - * @param artifact - * @return PlayerCombatPoint - */ - public static int getBattlePlayerCombatPoint(PlayerInfoMongo player, ItemDefMongo weapon, ItemDefMongo artifact) { + public static int getPlayerWeaponDmg(PlayerInfoMongo player, ItemDefMongo weapon) { int baseCombatPoint = 22; // 맨손일 때 if (weapon == null) { @@ -217,6 +211,40 @@ public static int getBattlePlayerCombatPoint(PlayerInfoMongo player, ItemDefMong } + /** + * @param player + * @param weapon + * @return PlayerCombatPoint + */ + public static int getBattlePlayerCombatPoint( + PlayerInfoMongo player, + ItemDefMongo weapon, + EffectGradeDefRepository effectGradeDefRepository + ) { + int combatPoint = getPlayerWeaponDmg(player, weapon); + + if (weapon == null) { + return combatPoint; + } + + double totalMultiplier = 1.0; + + for (ItemEffectMongo effect : weapon.getItemEffect()) { + EffectProbability prob = effect.getEffectProbability(); + var defOpt = effectGradeDefRepository.findByEffectProbability(prob); + if (defOpt.isPresent()) { + totalMultiplier += defOpt.get().getWeight(); // 여기서는 문제 없음 + } + } + + // 최종 계산 + combatPoint = (int) Math.round(combatPoint * totalMultiplier); + + return combatPoint; + } + + + public static int simulateBattle(int playerCombatPoint, int npcCombatPoint){ int maxNum = playerCombatPoint + npcCombatPoint; int num = secureRandom.nextInt(maxNum) + 1; @@ -229,49 +257,34 @@ public static List> getBattleLog(int playerWin, int playerDmg, int List> hpLog = new ArrayList<>(); int npcHp = getNpcHealthPoint(npcRank); + int playerCurrentHp = playerHp; + int npcCurrentHp = npcHp; int maxTurns = 10; - boolean playerVictory = playerWin == 1; - int prevPlayerHp = playerHp; - int prevNpcHp = npcHp; + hpLog.add(Arrays.asList(playerCurrentHp, npcCurrentHp)); - for (int turn = 0; turn < maxTurns; turn++) { - if (playerHp <= 0 || npcHp <= 0) break; - // 랜덤 데미지 ±10% - int actualPlayerDmg = (int) Math.round(playerDmg * (0.9 + secureRandom.nextDouble() * 0.2)); - int actualNpcDmg = (int) Math.round(npcDmg * (0.9 + secureRandom.nextDouble() * 0.2)); + for (int turn = 1; turn <= maxTurns; turn++) { + // 공격 + npcCurrentHp -= playerDmg; + playerCurrentHp -= npcDmg; - npcHp -= actualPlayerDmg; - playerHp -= actualNpcDmg; - - // 승리 쪽이 턴 중 0 이하가 되면 이전 HP 범위에서 랜덤 회복 - if (playerVictory && playerHp <= 0) { - playerHp = secureRandom.nextInt(prevPlayerHp) + 1; - } else if (!playerVictory && npcHp <= 0) { - npcHp = secureRandom.nextInt(prevNpcHp) + 1; - } + // 최소 0으로 + if (npcCurrentHp < 0) npcCurrentHp = 0; + if (playerCurrentHp < 0) playerCurrentHp = 0; - hpLog.add(List.of(Math.max(playerHp, 0), Math.max(npcHp, 0))); + hpLog.add(Arrays.asList(playerCurrentHp, npcCurrentHp)); - prevPlayerHp = playerHp; - prevNpcHp = npcHp; + // 승패 체크 + if (playerWin == 1 && npcCurrentHp == 0) break; + if (playerWin == 0 && playerCurrentHp == 0) break; } - // 마지막 턴에 승리 쪽 HP 최소 1로 보정 - if (playerVictory) { - npcHp = 0; - playerHp = Math.max(playerHp, 1); - } else { - playerHp = 0; - npcHp = Math.max(npcHp, 1); - } - hpLog.add(List.of(playerHp, npcHp)); - return hpLog; } + public static int getNpcCombatPoint(int npcRank) { int base = NPC_BATTLE_STATS[npcRank][1]; From dcb0cce465ce64b49d5ef04423fa3a4757f8d759 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 00:30:30 +0900 Subject: [PATCH 340/527] =?UTF-8?q?refactor:=20=C3=A3=C2=85=20change=20Cre?= =?UTF-8?q?ateItem=20to=20FastAPI=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/config/fastapi/FastApiEndpoint.java | 3 ++- .../demo/service/ItemDefService.java | 25 ++++++------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java index ea7d2968..ecc9b1cb 100644 --- a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java @@ -3,7 +3,8 @@ public enum FastApiEndpoint { INIT("/games/init"), CHOICE("/games/choice"), - BATTLE("/games/battle"); + BATTLE("/games/battle"), + ITEM("/games/item"); private final String path; diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 62f0e923..944ef091 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -4,6 +4,8 @@ import com.scriptopia.demo.domain.mongo.ItemDefMongo; import com.scriptopia.demo.domain.mongo.ItemEffectMongo; import com.scriptopia.demo.dto.items.*; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.EffectGradeDefRepository; import com.scriptopia.demo.repository.ItemDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; @@ -30,6 +32,7 @@ public class ItemDefService { private final ItemGradeDefRepository itemGradeDefRepository; private final EffectGradeDefRepository effectGradeDefRepository; private final ItemDefMongoRepository itemDefMongoRepository; + private final FastApiService fastApiService; @Transactional(readOnly = false) @@ -89,23 +92,12 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { .build(); - // 추후 config 에서 통합관리 - WebClient client = WebClient.builder() - .baseUrl("http://localhost:8000") // FastAPI 서버 주소 - .build(); - - - ItemFastApiResponse response = client.post() - .uri("/games/item") // FastAPI 엔드포인트 - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .bodyValue(fastRequest) // 생성한 ItemFastApiRequest 객체 - .retrieve() - .bodyToMono(ItemFastApiResponse.class) - .block(); // 블로킹 호출 (간단 테스트용) + ItemFastApiResponse response = fastApiService.item(fastRequest); + if (response == null) { + throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); + } - System.out.println("Fast Api = " + response); List mongoEffects = new ArrayList<>(); List apiEffects = response.getItemEffect(); @@ -165,9 +157,6 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { } itemDefRdb.setItemEffects(rdbEffects); - - System.out.println(itemDefRdb); - itemDefRepository.save(itemDefRdb); return response; From 9573f852d1a77a5bb1dbbfbf3d58686bd6183867 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 00:34:14 +0900 Subject: [PATCH 341/527] feat: create item endPoint method --- .../com/scriptopia/demo/service/FastApiService.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index d57590c9..0243f389 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -2,6 +2,8 @@ import com.scriptopia.demo.config.fastapi.FastApiEndpoint; import com.scriptopia.demo.dto.gamesession.*; +import com.scriptopia.demo.dto.items.ItemFastApiRequest; +import com.scriptopia.demo.dto.items.ItemFastApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @@ -41,4 +43,14 @@ public CreateGameBattleResponse battle(CreateGameBattleRequest request) { .bodyToMono(CreateGameBattleResponse.class) .block(); } + + // 아이템 생성 (확장용) + public ItemFastApiResponse item(ItemFastApiRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.ITEM.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(ItemFastApiResponse.class) + .block(); + } } From f8ea14d14570f7a69a5edfad02eb9c8fc51e1b3d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 02:58:01 +0900 Subject: [PATCH 342/527] refactor: update new battle log logic --- .../demo/service/GameSessionService.java | 9 +-- .../demo/utils/GameBalanceUtil.java | 62 +++++++++++++------ 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index fab9ad1e..f9b927e3 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -498,7 +498,7 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { .playerArtifact(artifact != null ? mapToItemEffect(artifact) : null) .npcName(gameSessionMongo.getNpcInfo().getName()) .npcTrait(gameSessionMongo.getNpcInfo().getTrait()) - .npcDmg(gameSessionMongo.getNpcInfo().getStrength()) + .npcDmg(npcCombatPoint) .npcWeapon(gameSessionMongo.getNpcInfo().getNpcWeaponName()) .npcWeaponDescription(gameSessionMongo.getNpcInfo().getNpcWeaponDescription()) .battleResult(playerWin) @@ -506,10 +506,10 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { .build(); - CreateGameBattleResponse fastApiResponse = fastApiService.battle(fastApiRequest); - System.out.println("fastApiRequest = " + fastApiRequest); - System.out.println("전투로그 = " + battleLog + " 턴 = " + battleLog.size()); + System.out.println("플레이어 공격력 = " + playerDmg + "플레이어 체력 = " + playerHp + " npc 공격력 = " + npcCombatPoint + " npc 체력 = " + GameBalanceUtil.getNpcHealthPoint(npcRank)); + System.out.println("전투로그 = " + battleLog + " 턴 = " + battleLog.size() + " 이긴사람 = " + playerWin); + CreateGameBattleResponse fastApiResponse = fastApiService.battle(fastApiRequest); if (fastApiResponse == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); @@ -517,6 +517,7 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { return fastApiResponse; + } // ItemDefMongo -> CreateGameBattleRequest.Item 변환 diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index c7a3392a..6f704e3d 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -252,38 +252,62 @@ public static int simulateBattle(int playerCombatPoint, int npcCombatPoint){ return playerCombatPoint >= num ? 1 : 0 ; } - - public static List> getBattleLog(int playerWin, int playerDmg, int playerHp, int npcDmg, int npcRank) { + public static List> getBattleLog( + int playerWin, + int playerDmg, + int playerHp, + int npcDmg, + int npcRank + ) { List> hpLog = new ArrayList<>(); int npcHp = getNpcHealthPoint(npcRank); - int playerCurrentHp = playerHp; - int npcCurrentHp = npcHp; - int maxTurns = 10; + int winnerDmg = (playerWin == 1) ? playerDmg : npcDmg; + int loserDmg = (playerWin == 1) ? npcDmg : playerDmg; + + int winnerCurrentHp = (playerWin == 1) ? playerHp : npcHp; + int loserCurrentHp = (playerWin == 1) ? npcHp : playerHp; + + int winnerIndex = (playerWin == 1) ? 0 : 1; + int loserIndex = (playerWin == 1) ? 1 : 0; + + List originHp = new ArrayList<>(Arrays.asList(0, 0)); + + originHp.set(loserIndex, loserCurrentHp); + originHp.set(winnerIndex, winnerCurrentHp); + + hpLog.add(new ArrayList<>(originHp)); // 초기 로그 추가 + List currentHp = originHp; - hpLog.add(Arrays.asList(playerCurrentHp, npcCurrentHp)); + while (loserCurrentHp > 0) { - for (int turn = 1; turn <= maxTurns; turn++) { - // 공격 - npcCurrentHp -= playerDmg; - playerCurrentHp -= npcDmg; - // 최소 0으로 - if (npcCurrentHp < 0) npcCurrentHp = 0; - if (playerCurrentHp < 0) playerCurrentHp = 0; + // 패배자에게만 데미지 적용 + int damage = (int) Math.round(winnerDmg * (0.9 + 0.4 * secureRandom.nextDouble())); + loserCurrentHp -= damage; + if (loserCurrentHp < 0) loserCurrentHp = 0; - hpLog.add(Arrays.asList(playerCurrentHp, npcCurrentHp)); + // winnerIndex, loserIndex 기준으로 정확히 넣기 + currentHp.set(winnerIndex, winnerCurrentHp); + currentHp.set(loserIndex, loserCurrentHp); - // 승패 체크 - if (playerWin == 1 && npcCurrentHp == 0) break; - if (playerWin == 0 && playerCurrentHp == 0) break; + hpLog.add(new ArrayList<>(currentHp)); } - return hpLog; - } + // 승리자 최소 HP (패배자가 0일 때) + int winnerMaxHp = (int) (winnerCurrentHp * (0.1 + secureRandom.nextDouble() * 0.4)); + + for (int i = 1 ; i < hpLog.size(); i++) { + List turnHp = hpLog.get(i); + int damage = (int) Math.round(loserDmg * (0.9 + 0.4 * secureRandom.nextDouble())); + winnerCurrentHp = Math.max(winnerCurrentHp - damage, winnerMaxHp); + turnHp.set(winnerIndex,winnerCurrentHp); + } + return hpLog; + } public static int getNpcCombatPoint(int npcRank) { int base = NPC_BATTLE_STATS[npcRank][1]; From 93239d960824aede160cb4781bd0871739501831 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 07:57:11 +0900 Subject: [PATCH 343/527] feat: create battle logic --- .../demo/domain/mongo/BattleInfoMongo.java | 9 +++--- ...leTurnMongo.java => BattleStoryMongo.java} | 3 +- .../demo/service/GameSessionService.java | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) rename src/main/java/com/scriptopia/demo/domain/mongo/{BattleTurnMongo.java => BattleStoryMongo.java} (71%) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java index 892426c4..e1e12cc7 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleInfoMongo.java @@ -9,8 +9,9 @@ @AllArgsConstructor @NoArgsConstructor public class BattleInfoMongo { - private Long curTurnId; - private List playerHp; - private List enemyHp; - private List battleTurn; + private Long curTurnId; // 현재 턴 + private List playerHp; // 플레이어 HP 로그 + private List enemyHp; // NPC HP 로그 + private List battleTurn;// 수치 기반 턴 기록 + private Boolean playerWin; // 승패 여부 } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/BattleStoryMongo.java similarity index 71% rename from src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java rename to src/main/java/com/scriptopia/demo/domain/mongo/BattleStoryMongo.java index be6a20e1..1886adf3 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/BattleTurnMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/BattleStoryMongo.java @@ -6,7 +6,6 @@ @Builder @AllArgsConstructor @NoArgsConstructor -public class BattleTurnMongo { - private Integer turnId; +public class BattleStoryMongo { private String turnInfo; } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f9b927e3..279ca117 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -515,11 +515,39 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } + + List turnLogs = fastApiResponse.getBattleInfo().getTurnInfo() + .stream() + .map(info -> BattleStoryMongo.builder().turnInfo(info).build()) + .toList(); + + BattleInfoMongo battleInfoMongo = BattleInfoMongo.builder() + .curTurnId(0L) + .playerHp(battleLog.stream().map(t -> t.get(0).longValue()).toList()) + .enemyHp(battleLog.stream().map(t -> t.get(1).longValue()).toList()) + .battleTurn(turnLogs) + .playerWin( playerWin == 1 ) + .build(); + + + gameSessionMongo.setBattleInfo(battleInfoMongo); + gameSessionMongo.setBackground(fastApiResponse.getBattleInfo().getReCap()); + gameSessionMongo.setSceneType(SceneType.BATTLE); + gameSessionMongo.setUpdatedAt(LocalDateTime.now()); + + + gameSessionMongoRepository.save(gameSessionMongo); + return fastApiResponse; } + + /** + * battle에서 사용 + * item -> request로 쉽게 매필 + */ // ItemDefMongo -> CreateGameBattleRequest.Item 변환 private CreateGameBattleRequest.Item mapToItemEffect(ItemDefMongo item) { List effects = item.getItemEffect().stream() From ad117e38938699714866472894ec8dc99631eb4d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 07:58:57 +0900 Subject: [PATCH 344/527] refactor: delete testController in GameSessionController --- .../demo/controller/GameSessionController.java | 12 ------------ .../scriptopia/demo/service/GameSessionService.java | 6 ++---- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 29d6c51b..61026010 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -47,18 +47,6 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } - /** - * 테스트 중 - */ - @PostMapping("/testC") - public ResponseEntity testCoGame( - Authentication authentication) throws JsonProcessingException { - - Long userId = Long.valueOf(authentication.getName()); - - GameSessionMongo response = gameSessionService.mapToCreateGameChoiceRequest(userId); - return ResponseEntity.ok(response); - } /** * 테스트 중 diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 279ca117..1e2dec61 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -267,7 +267,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { @Transactional - public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { + public int mapToCreateGameChoiceRequest(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); @@ -429,7 +429,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { gameSessionMongo.setHistoryInfo(historyInfoMongo); gameSessionMongoRepository.save(gameSessionMongo); - return gameSessionMongo; + return 1; } @@ -539,8 +539,6 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { gameSessionMongoRepository.save(gameSessionMongo); return fastApiResponse; - - } From 1c564cf9aa7ae5757dc643d65185f4f6f05a9400 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 08:15:46 +0900 Subject: [PATCH 345/527] feat: create RewardType enum --- .../scriptopia/demo/domain/RewardType.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/domain/RewardType.java diff --git a/src/main/java/com/scriptopia/demo/domain/RewardType.java b/src/main/java/com/scriptopia/demo/domain/RewardType.java new file mode 100644 index 00000000..cc7ef324 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/RewardType.java @@ -0,0 +1,36 @@ +package com.scriptopia.demo.domain; + + +import java.security.SecureRandom; + +public enum RewardType { + GOLD(40), + LIFE(30), + ITEM(20), + STAT(10); + + private final int dropRate; + private static final SecureRandom random = new SecureRandom(); + + RewardType(int dropRate) { + this.dropRate = dropRate; + } + + public int getDropRate() { + return dropRate; + } + + // 랜덤 보상 추출 + public static RewardType getRandomRewardType() { + int rand = random.nextInt(100) + 1; // 1~100 + int cumulative = 0; + + for (RewardType reward : RewardType.values()) { + cumulative += reward.getDropRate(); + if (rand <= cumulative) { + return reward; + } + } + return null; // 혹은 기본값 + } +} From be877d440a0e41d1f8fe89cf9c3eb29388e572e4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 08:21:55 +0900 Subject: [PATCH 346/527] refactor: delete LIFE and add NONE enum --- src/main/java/com/scriptopia/demo/domain/RewardType.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/RewardType.java b/src/main/java/com/scriptopia/demo/domain/RewardType.java index cc7ef324..aff1ca6b 100644 --- a/src/main/java/com/scriptopia/demo/domain/RewardType.java +++ b/src/main/java/com/scriptopia/demo/domain/RewardType.java @@ -4,10 +4,10 @@ import java.security.SecureRandom; public enum RewardType { - GOLD(40), - LIFE(30), - ITEM(20), - STAT(10); + GOLD(60), + STAT(10), + ITEM(10), + NONE(20); private final int dropRate; private static final SecureRandom random = new SecureRandom(); From e404cfcca9d68a55d6874f8040ba1882ec64d1ba Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 08:22:39 +0900 Subject: [PATCH 347/527] refactor: change choice mongoDB --- .../java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java | 3 +++ .../java/com/scriptopia/demo/service/GameSessionService.java | 1 + 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java index 3c5e3e24..3c80f292 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ChoiceMongo.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.domain.mongo; import com.scriptopia.demo.domain.ChoiceResultType; +import com.scriptopia.demo.domain.RewardType; import com.scriptopia.demo.domain.Stat; import lombok.*; @@ -14,4 +15,6 @@ public class ChoiceMongo { private Stat stats; // STRENGTH, AGILITY, INTELLIGENCE, LUCK private Integer probability; private ChoiceResultType resultType; // BATTLE, SHOP, CHOICE, NONE + private RewardType rewardType; // GOLD, STAT, ITEM, NONE + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 1e2dec61..6899bc1a 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -391,6 +391,7 @@ public int mapToCreateGameChoiceRequest(Long userId) { .stats(statInfo.get(i)) .probability(null) .resultType(ChoiceResultType.nextResultType()) + .rewardType(RewardType.getRandomRewardType()) .build(); choiceList.add(choiceMongo); From 128e725f106cd944553b11c31234f23119b03e29 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 08:32:37 +0900 Subject: [PATCH 348/527] refactor: add rewardType --- .../demo/controller/GameSessionController.java | 12 ++++++++++++ .../scriptopia/demo/service/GameSessionService.java | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 61026010..29d6c51b 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -47,6 +47,18 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } + /** + * 테스트 중 + */ + @PostMapping("/testC") + public ResponseEntity testCoGame( + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + GameSessionMongo response = gameSessionService.mapToCreateGameChoiceRequest(userId); + return ResponseEntity.ok(response); + } /** * 테스트 중 diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 6899bc1a..d359528e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -267,7 +267,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { @Transactional - public int mapToCreateGameChoiceRequest(Long userId) { + public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); @@ -358,7 +358,7 @@ public int mapToCreateGameChoiceRequest(Long userId) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } - + gameSessionMongo.setSceneType(SceneType.CHOICE); gameSessionMongo.setUpdatedAt(LocalDateTime.now()); gameSessionMongo.setBackground(createGameChoiceResponse.getChoiceInfo().getStory()); gameSessionMongo.setProgress(gameSessionMongo.getProgress()); @@ -430,7 +430,7 @@ public int mapToCreateGameChoiceRequest(Long userId) { gameSessionMongo.setHistoryInfo(historyInfoMongo); gameSessionMongoRepository.save(gameSessionMongo); - return 1; + return gameSessionMongo; } From 65afba5f04ae14db6ec2bb647d2074a4beab919e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 11:24:42 +0900 Subject: [PATCH 349/527] refactor: add getChoiceProbability to baseProbability 0.9 ~ 1.1 multi --- .../demo/utils/GameBalanceUtil.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 6f704e3d..30450686 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -132,6 +132,30 @@ public static Long getRandomItemPriceByGrade(List effectGradeList) { return effectPrice; } + public static Integer getChoiceProbability(Stat stat, PlayerInfoMongo playerInfo) { + int baseProbability = 40; // 기본 선택지 확률 40% + double multiplier = 0.9 + (0.2 * secureRandom.nextDouble()); // 0.9 ~ 1.1 + baseProbability = (int) (baseProbability * multiplier); + + double statBonus = 0; + + switch (stat) { + case STRENGTH -> statBonus = playerInfo.getStrength() * 0.8; + case AGILITY -> statBonus = playerInfo.getAgility() * 0.8; + case INTELLIGENCE -> statBonus = playerInfo.getIntelligence() * 0.8; + case LUCK -> statBonus = playerInfo.getLuck() * 0.8; + } + + double probability = baseProbability + statBonus; + + // 최소 30%, 최대 80% 제한 + probability = Math.max(30, Math.min(80, probability)); + + return (int) Math.round(probability); + } + + + /** * 플레이어 기본 스탯 초기화 * @param playerStat (플레이어가 선택한 주 스탯) From 5f3d265b8b41267c9972a201c431f606ef402889 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 11:43:10 +0900 Subject: [PATCH 350/527] feat: create CreateGameDoneRequest and CreateGameDoneResponse --- .../gamesession/CreateGameDoneRequest.java | 25 +++++++++++++++++++ .../gamesession/CreateGameDoneResponse.java | 4 +++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java new file mode 100644 index 00000000..ed05e540 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java @@ -0,0 +1,25 @@ +package com.scriptopia.demo.dto.gamesession; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateGameDoneRequest { + + private String worldView; + private String location; + + private String previousStory; + private String selectedChoice; + + private String resultContent; + private String playerName; + private String playerVictory; + +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java new file mode 100644 index 00000000..18585bf6 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession; + +public class CreateGameDoneResponse { +} From 9ce3c0106d129bd3813643a726ad81488b11b9fc Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 13:18:53 +0900 Subject: [PATCH 351/527] feat: creat DONE endPoint --- .../com/scriptopia/demo/config/fastapi/FastApiEndpoint.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java index ecc9b1cb..415c05e9 100644 --- a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java @@ -4,7 +4,8 @@ public enum FastApiEndpoint { INIT("/games/init"), CHOICE("/games/choice"), BATTLE("/games/battle"), - ITEM("/games/item"); + ITEM("/games/item"), + DONE("/games/done"); private final String path; From 980efdc99bc105fdc0d61d8d9c5044d9ab5f3345 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 13:19:43 +0900 Subject: [PATCH 352/527] feat: create Done to FAST API request, response --- .../gamesession/CreateGameDoneRequest.java | 2 +- .../gamesession/CreateGameDoneResponse.java | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java index ed05e540..8708a26a 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java @@ -22,4 +22,4 @@ public class CreateGameDoneRequest { private String playerName; private String playerVictory; -} +} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java index 18585bf6..7089d14e 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneResponse.java @@ -1,4 +1,27 @@ package com.scriptopia.demo.dto.gamesession; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class CreateGameDoneResponse { + private DoneInfo doneInfo; + + + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DoneInfo { + private String newLocation; + private String reCap; + } + } From 778687b608ec968f40428fae2f845a0119281827 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:25:59 +0900 Subject: [PATCH 353/527] feat: create SecurityWhitelist for config public uri --- .../demo/config/SecurityWhitelist.java | 18 ++++++++++++++++++ .../demo/exception/GlobalExceptionHandler.java | 14 +++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java new file mode 100644 index 00000000..d08f256a --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.config; + + +public class SecurityWhitelist { + public static final String[] AUTH_WHITELIST = { + "/auth/logout", + "/auth/login", + "/auth/register", + "/auth/email/**", + "/auth/password/reset/**", + + + }; + + public static final String[] PUBLIC_GETS = { + "/trades" + }; +} diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index a557005b..a61e62af 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -71,11 +71,11 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); } -// @ExceptionHandler(Exception.class) -// public ResponseEntity handleGeneralException(Exception ex) { -// -// return ResponseEntity -// .status(HttpStatus.INTERNAL_SERVER_ERROR) -// .body(new ErrorResponse(ErrorCode.E_500)); -// } + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(ErrorCode.E_500)); + } } From d5e73c84e669724a1a781e102a00822b9f01f2f1 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:28:32 +0900 Subject: [PATCH 354/527] feat: refactoring auth uri remove role from uri centralized role based access control in securityConfig --- .../demo/config/SecurityConfig.java | 109 +++--------------- 1 file changed, 19 insertions(+), 90 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 7b6141d2..39868bb8 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.dto.exception.ErrorResponse; +import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import lombok.RequiredArgsConstructor; @@ -36,103 +37,31 @@ public PasswordEncoder passwordEncoder() { private final JwtProvider jwtProvider; @Bean - @Order(1) - public SecurityFilterChain authChain(HttpSecurity http) throws Exception { - - http.securityMatcher("/auth/**") - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ) - .csrf(AbstractHttpConfigurer::disable); - - http.addFilterBefore((request, response, chain) -> { - System.out.println("[SecurityChain-1]"); - chain.doFilter(request, response); - }, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - - @Bean - @Order(2) - public SecurityFilterChain oAuthChain(HttpSecurity http) throws Exception { - - http.securityMatcher("/oauth/**") - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ) - .csrf(AbstractHttpConfigurer::disable); - - http.addFilterBefore((request, response, chain) -> { - System.out.println("[SecurityChain-2]"); - chain.doFilter(request, response); - }, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - - @Bean - @Order(3) - public SecurityFilterChain publicTradesChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .securityMatcher("/trades") // 공개 체인: GET /trades 만 + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.GET, "/trades").permitAll() - ) - .csrf(AbstractHttpConfigurer::disable); - - http.addFilterBefore((request, response, chain) -> { - System.out.println("[SecurityChain-3]"); - chain.doFilter(request, response); - }, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } + //public 권한 + .requestMatchers( + SecurityWhitelist.AUTH_WHITELIST + ).permitAll() + //user 권한 + .requestMatchers( + "/auth/password/change" + ).hasAuthority("USER") - @Bean - @Order(99) // public 체인보다 뒤에서 동작 - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - - http.addFilterBefore((request, response, chain) -> { - System.out.println("[SecurityChain-99]"); - chain.doFilter(request, response); - }, UsernamePasswordAuthenticationFilter.class); - + //admin 권한 + .requestMatchers( + "" + ).hasAuthority("ADMIN") - http - .securityMatcher("/**") - .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(req -> { - var c = new CorsConfiguration(); - c.setAllowedOrigins(List.of("http://localhost:3000")); - c.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH","OPTIONS")); - c.setAllowedHeaders(List.of("Authorization","Content-Type")); - c.setAllowCredentials(true); - c.setMaxAge(3600L); - return c; - })) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .anyRequest().authenticated() // 나머지 전부 인증 필요 + .anyRequest().authenticated() ) - .addFilterBefore(new JwtAuthFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) - .exceptionHandling(ex -> ex - .authenticationEntryPoint((req, res, e) -> { - res.setStatus(ErrorCode.E_401.getStatus().value()); - res.setContentType(MediaType.APPLICATION_JSON_VALUE); - new ObjectMapper().writeValue(res.getOutputStream(), - new ErrorResponse(ErrorCode.E_401)); - }) - .accessDeniedHandler((req, res, e) -> { - res.setStatus(E_403.getStatus().value()); - res.setContentType(MediaType.APPLICATION_JSON_VALUE); - new ObjectMapper().writeValue(res.getOutputStream(), - new ErrorResponse(E_403)); - }) - ); + .addFilterBefore(new JwtAuthFilter(jwtProvider), + UsernamePasswordAuthenticationFilter.class); return http.build(); } } \ No newline at end of file From bde6f5379af7c3612482357d44768d6996dd4e87 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:30:07 +0900 Subject: [PATCH 355/527] feat: add EnableMethodSEcurity for use Preauthorize annotation --- .../com/scriptopia/demo/config/SecurityConfig.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 39868bb8..8f59a7b0 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,16 +1,10 @@ package com.scriptopia.demo.config; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.scriptopia.demo.dto.exception.ErrorResponse; -import com.scriptopia.demo.exception.CustomException; -import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -19,14 +13,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import java.util.List; - -import static com.scriptopia.demo.exception.ErrorCode.E_403; @Configuration @EnableWebSecurity @RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { @Bean From cf7c27734d43b3196156bf350cb9a63c799dc33f Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:38:34 +0900 Subject: [PATCH 356/527] feat: replace URI-based role validation with Spring Security authority checks --- .../scriptopia/demo/config/JwtAuthFilter.java | 33 +++++++++++++++++-- .../demo/config/SecurityConfig.java | 7 ++-- .../demo/controller/AuthController.java | 2 ++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 2cd574eb..9683d866 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scriptopia.demo.dto.exception.ErrorResponse; -import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import io.jsonwebtoken.ExpiredJwtException; @@ -13,28 +12,45 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.io.IOException; -import java.security.SignatureException; +import java.util.Arrays; import java.util.stream.Collectors; - +@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtProvider jwt; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + @Value("${server.servlet.context-path}") + private String contextPath; @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { + String path = req.getServletPath(); + String method = req.getMethod(); + + System.out.println("Request path: " + path); + System.out.println("Whitelist match: " + isWhitelisted(path, method)); + + if (isWhitelisted(path, method)) { + chain.doFilter(req, res); + return; + } + String authHeader = req.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { setErrorResponse(res, ErrorCode.E_400_MISSING_JWT); @@ -75,7 +91,18 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, chain.doFilter(req, res); } + private boolean isWhitelisted(String path, String method) { + + + boolean authMatch = Arrays.stream(SecurityWhitelist.AUTH_WHITELIST) + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + boolean publicGetMatch = "GET".equalsIgnoreCase(method) && + Arrays.stream(SecurityWhitelist.PUBLIC_GETS) + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + + return authMatch || publicGetMatch; + } private void setErrorResponse(HttpServletResponse res, ErrorCode code) throws IOException { diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 8f59a7b0..44541c9d 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -25,7 +25,7 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - private final JwtProvider jwtProvider; + private final JwtAuthFilter jwtAuthFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -46,13 +46,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti //admin 권한 .requestMatchers( - "" + "/admin/**" ).hasAuthority("ADMIN") .anyRequest().authenticated() ) - .addFilterBefore(new JwtAuthFilter(jwtProvider), - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index e3bbcd1d..14a81c1e 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -12,6 +12,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -102,6 +103,7 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest } + @PreAuthorize("hasAnyAuthority('USER')") @PatchMapping("/password/change") public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, Authentication authentication) { From 21172e875b3da7a2e4ee83f8b73f7669d69e8405 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:52:44 +0900 Subject: [PATCH 357/527] feat: add oauth endpoints to whilelist for public access --- .../scriptopia/demo/config/JwtAuthFilter.java | 8 +++--- .../demo/config/SecurityConfig.java | 28 ++++++++++++++----- .../demo/config/SecurityWhitelist.java | 2 ++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 9683d866..8c4fc695 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -14,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -27,14 +28,13 @@ import java.util.Arrays; import java.util.stream.Collectors; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtProvider jwt; private final AntPathMatcher pathMatcher = new AntPathMatcher(); - @Value("${server.servlet.context-path}") - private String contextPath; @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) @@ -43,8 +43,8 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, String path = req.getServletPath(); String method = req.getMethod(); - System.out.println("Request path: " + path); - System.out.println("Whitelist match: " + isWhitelisted(path, method)); + log.debug("Request path: {}", path); + log.debug("Whitelist match: {}", isWhitelisted(path, method)); if (isWhitelisted(path, method)) { chain.doFilter(req, res); diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 44541c9d..5ebde3ef 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -1,9 +1,13 @@ package com.scriptopia.demo.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scriptopia.demo.dto.exception.ErrorResponse; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -38,12 +42,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( SecurityWhitelist.AUTH_WHITELIST ).permitAll() - - //user 권한 - .requestMatchers( - "/auth/password/change" - ).hasAuthority("USER") - //admin 권한 .requestMatchers( "/admin/**" @@ -51,7 +49,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((req, res, e) -> { + res.setStatus(ErrorCode.E_401.getStatus().value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(res.getOutputStream(), + new ErrorResponse(ErrorCode.E_401)); + }) + .accessDeniedHandler((req, res, e) -> { + res.setStatus(ErrorCode.E_403.getStatus().value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(res.getOutputStream(), + new ErrorResponse(ErrorCode.E_403)); + }) + ); return http.build(); } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index d08f256a..faebd7c6 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -8,6 +8,8 @@ public class SecurityWhitelist { "/auth/register", "/auth/email/**", "/auth/password/reset/**", + "/oauth/**" + }; From dc055758d283f81739382cbd2ece9c25e64c1abe Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 14:49:01 +0900 Subject: [PATCH 358/527] refactor: add done to Fast API method --- .../com/scriptopia/demo/service/FastApiService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index 0243f389..53ce7b56 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -44,6 +44,17 @@ public CreateGameBattleResponse battle(CreateGameBattleRequest request) { .block(); } + // 결과 생성 (확장용) + public CreateGameDoneResponse done(CreateGameDoneRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.DONE.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(CreateGameDoneResponse.class) + .block(); + } + + // 아이템 생성 (확장용) public ItemFastApiResponse item(ItemFastApiRequest request) { return fastApiWebClient.post() @@ -53,4 +64,6 @@ public ItemFastApiResponse item(ItemFastApiRequest request) { .bodyToMono(ItemFastApiResponse.class) .block(); } + + } From 4cefdb379f569399235e0212e6b8793fbc10f4a3 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 14:58:47 +0900 Subject: [PATCH 359/527] refactor: add preReward to done method --- .../java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 0c63cc64..4bf95b83 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -27,6 +27,7 @@ public class GameSessionMongo { private String background; private String preChoice; + private String preReward; private String location; private Integer progress; private List stage; From 1c81e13a3a3f7a481304221f413561d7ae7e6f60 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:26:45 +0900 Subject: [PATCH 360/527] feat: remove role in trades endpoint --- .../demo/controller/AuctionController.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 76eea1a9..75a197f6 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -15,8 +15,7 @@ public class AuctionController { private final AuctionService auctionService; - - @GetMapping("/trades") + @GetMapping public ResponseEntity getTrades( @RequestBody TradeFilterRequest requestDto) { @@ -25,7 +24,7 @@ public ResponseEntity getTrades( } - @PostMapping("/trades/{auctionId}/purchase") + @PostMapping("/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, Authentication authentication) { @@ -36,7 +35,7 @@ public ResponseEntity purchaseItem( return ResponseEntity.ok(result); } - @GetMapping("/trades/me") + @GetMapping("/me") public ResponseEntity mySaleItems( @RequestBody MySaleItemRequest requestDto, Authentication authentication) { @@ -47,7 +46,7 @@ public ResponseEntity mySaleItems( return ResponseEntity.ok(result); } - @PostMapping("/trades") + @PostMapping public ResponseEntity createAuction(@RequestBody AuctionRequest dto, Authentication authentication ){ @@ -55,7 +54,7 @@ public ResponseEntity createAuction(@RequestBody AuctionRequest dto, return ResponseEntity.ok(auctionService.createAuction(dto, userId)); } - @DeleteMapping("/trades/{auctionId}") + @DeleteMapping("/{auctionId}") public ResponseEntity cancelMySaleItem( @PathVariable String auctionId, Authentication authentication) { @@ -66,7 +65,7 @@ public ResponseEntity cancelMySaleItem( } - @GetMapping("/trades/me/history") + @GetMapping("/me/history") public ResponseEntity settlementHistory( @RequestBody SettlementHistoryRequest requestDto, Authentication authentication) { @@ -77,7 +76,7 @@ public ResponseEntity settlementHistory( return ResponseEntity.ok(result); } - @PatchMapping("/trades/{settlementId}/confirm") + @PatchMapping("/{settlementId}/confirm") public ResponseEntity confirmItem( @PathVariable String settlementId, Authentication authentication) { From d65bd2cae82e788e56c9195c3dda4e37dbf007c4 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:27:33 +0900 Subject: [PATCH 361/527] feat: create shouldNotFilter for pass public api --- .../scriptopia/demo/config/JwtAuthFilter.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 8c4fc695..5072b6de 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -36,21 +36,34 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final JwtProvider jwt; private final AntPathMatcher pathMatcher = new AntPathMatcher(); + @Override - protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) - throws ServletException, IOException { + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getServletPath(); + String method = request.getMethod(); - String path = req.getServletPath(); - String method = req.getMethod(); + boolean authMatch = Arrays.stream(SecurityWhitelist.AUTH_WHITELIST) + .anyMatch(pattern -> pathMatcher.match(pattern, path)); - log.debug("Request path: {}", path); - log.debug("Whitelist match: {}", isWhitelisted(path, method)); + boolean publicGetMatch = "GET".equalsIgnoreCase(method) && + Arrays.stream(SecurityWhitelist.PUBLIC_GETS) + .anyMatch(pattern -> pathMatcher.match(pattern, path)); - if (isWhitelisted(path, method)) { - chain.doFilter(req, res); - return; + boolean skip = authMatch || publicGetMatch; + + if (skip) { + log.debug("➡️ Skipping JwtAuthFilter for whitelisted request: {} {}", method, path); } + return skip; + } + + + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + String authHeader = req.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { setErrorResponse(res, ErrorCode.E_400_MISSING_JWT); @@ -91,19 +104,6 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, chain.doFilter(req, res); } - private boolean isWhitelisted(String path, String method) { - - - boolean authMatch = Arrays.stream(SecurityWhitelist.AUTH_WHITELIST) - .anyMatch(pattern -> pathMatcher.match(pattern, path)); - - boolean publicGetMatch = "GET".equalsIgnoreCase(method) && - Arrays.stream(SecurityWhitelist.PUBLIC_GETS) - .anyMatch(pattern -> pathMatcher.match(pattern, path)); - - return authMatch || publicGetMatch; - } - private void setErrorResponse(HttpServletResponse res, ErrorCode code) throws IOException { res.setStatus(code.getStatus().value()); From d5c620f8cab87334e690c3f4a0a4537ee480059c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 15:28:32 +0900 Subject: [PATCH 362/527] refactor: add test to done Fast API --- .../demo/controller/GameSessionController.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 29d6c51b..18bc364d 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -61,10 +61,10 @@ public ResponseEntity testCoGame( } /** - * 테스트 중 + * 테스트 전투 */ - @PostMapping("/test") - public ResponseEntity testGame( + @PostMapping("/testB") + public ResponseEntity testBaGame( Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); @@ -73,6 +73,17 @@ public ResponseEntity testGame( return ResponseEntity.ok(response); } + + @PostMapping("/test") + public ResponseEntity testGame( + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + CreateGameDoneResponse response = gameSessionService.mapToCreateGameDoneRequest(userId); + return ResponseEntity.ok(response); + } + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 From 85aea0cdaa5bf48dab1b4033ece7a15bb4ac4a2e Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:29:12 +0900 Subject: [PATCH 363/527] refactor: move trades API access control to Spring Security --- .../demo/config/SecurityConfig.java | 22 ++++++++++++------- .../demo/config/SecurityWhitelist.java | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 5ebde3ef..07a61406 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -5,8 +5,11 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.utils.JwtProvider; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -18,6 +21,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import java.util.Arrays; + +@Slf4j @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -31,22 +37,22 @@ public PasswordEncoder passwordEncoder() { private final JwtAuthFilter jwtAuthFilter; + + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - //public 권한 - .requestMatchers( - SecurityWhitelist.AUTH_WHITELIST - ).permitAll() + .requestMatchers(SecurityWhitelist.AUTH_WHITELIST).permitAll() + //public 권한(GET 요청) + .requestMatchers(HttpMethod.GET,SecurityWhitelist.PUBLIC_GETS).permitAll() //admin 권한 - .requestMatchers( - "/admin/**" - ).hasAuthority("ADMIN") - + .requestMatchers("/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index faebd7c6..708daf58 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -3,6 +3,7 @@ public class SecurityWhitelist { public static final String[] AUTH_WHITELIST = { + "/error", "/auth/logout", "/auth/login", "/auth/register", From 9594a670eb78856bb1945b8363acce568c612651 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:07:14 +0900 Subject: [PATCH 364/527] feat: add ErrorController for return custom 404 error --- .../demo/controller/ErrorController.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/controller/ErrorController.java diff --git a/src/main/java/com/scriptopia/demo/controller/ErrorController.java b/src/main/java/com/scriptopia/demo/controller/ErrorController.java new file mode 100644 index 00000000..a45b231d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/ErrorController.java @@ -0,0 +1,33 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.exception.ErrorResponse; +import com.scriptopia.demo.exception.ErrorCode; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ErrorController implements org.springframework.boot.web.servlet.error.ErrorController { + + @RequestMapping("/error") + public ResponseEntity handleError(HttpServletRequest request) { + Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + + if (statusCode != null) { + int status = Integer.parseInt(statusCode.toString()); + + if (status == HttpStatus.NOT_FOUND.value()) { + return ResponseEntity + .status(ErrorCode.E_404.getStatus()) + .body(new ErrorResponse(ErrorCode.E_404)); + } + } + + return ResponseEntity + .status(ErrorCode.E_500.getStatus()) + .body(new ErrorResponse(ErrorCode.E_500)); + } +} From 779f39cb084717b8b46bc6c59e87f9e2a9c64911 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:08:11 +0900 Subject: [PATCH 365/527] feat: add error code 404000 --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 181c04b4..d9d86a60 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -58,6 +58,7 @@ public enum ErrorCode { //404 Not Found + E_404("E404000","요청하신 리소스를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_REFRESH_NOT_FOUND("E404001", "유효한 리프레시 세션을 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_USER_NOT_FOUND("E404002","사용자를 찾을 수 없습니다.",HttpStatus.NOT_FOUND), E_404_AUCTION_NOT_FOUND("E404003", "해당 아이템이 존재하지 않습니다.", HttpStatus.NOT_FOUND), From 664a98eca33e1c581b1fe14b3b2e6777d79941f1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 16:11:57 +0900 Subject: [PATCH 366/527] refactor: change test controller --- .../com/scriptopia/demo/controller/GameSessionController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 18bc364d..15a5e048 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -75,12 +75,12 @@ public ResponseEntity testBaGame( @PostMapping("/test") - public ResponseEntity testGame( + public ResponseEntity testGame( Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - CreateGameDoneResponse response = gameSessionService.mapToCreateGameDoneRequest(userId); + GameSessionMongo response = gameSessionService.mapToCreateGameDoneRequest(userId); return ResponseEntity.ok(response); } From dc6a51a758d3607d9bfc9941a5f775745fcd3b82 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 16:12:57 +0900 Subject: [PATCH 367/527] feat: create reward recap method --- .../scriptopia/demo/domain/RewardType.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/domain/RewardType.java b/src/main/java/com/scriptopia/demo/domain/RewardType.java index aff1ca6b..be6363c0 100644 --- a/src/main/java/com/scriptopia/demo/domain/RewardType.java +++ b/src/main/java/com/scriptopia/demo/domain/RewardType.java @@ -1,6 +1,9 @@ package com.scriptopia.demo.domain; +import com.scriptopia.demo.domain.mongo.GameSessionMongo; +import com.scriptopia.demo.domain.mongo.RewardInfoMongo; + import java.security.SecureRandom; public enum RewardType { @@ -33,4 +36,59 @@ public static RewardType getRandomRewardType() { } return null; // 혹은 기본값 } + + + public static String getRewardSummary(RewardInfoMongo rewardInfo) { + if (rewardInfo == null) { + return "보상이 없습니다."; + } + + StringBuilder sb = new StringBuilder(); + + // GOLD + if (rewardInfo.getRewardGold() != null && rewardInfo.getRewardGold() != 0) { + if (rewardInfo.getRewardGold() > 0) { + sb.append(rewardInfo.getRewardGold()).append(" 골드를 획득하였습니다.\n"); + } else { + sb.append(Math.abs(rewardInfo.getRewardGold())).append(" 골드를 잃었습니다.\n"); + } + } + + // STAT + if (rewardInfo.getRewardStrength() != null && rewardInfo.getRewardStrength() != 0) { + sb.append("힘 ").append(formatChange(rewardInfo.getRewardStrength())).append("\n"); + } + if (rewardInfo.getRewardAgility() != null && rewardInfo.getRewardAgility() != 0) { + sb.append("민첩 ").append(formatChange(rewardInfo.getRewardAgility())).append("\n"); + } + if (rewardInfo.getRewardIntelligence() != null && rewardInfo.getRewardIntelligence() != 0) { + sb.append("지능 ").append(formatChange(rewardInfo.getRewardIntelligence())).append("\n"); + } + if (rewardInfo.getRewardLuck() != null && rewardInfo.getRewardLuck() != 0) { + sb.append("운 ").append(formatChange(rewardInfo.getRewardLuck())).append("\n"); + } + if (rewardInfo.getRewardLife() != null && rewardInfo.getRewardLife() != 0) { + sb.append("생명 ").append(formatChange(rewardInfo.getRewardLife())).append("\n"); + } + + // TRAIT + if (rewardInfo.getRewardTrait() != null) { + sb.append("특성 획득: ").append(rewardInfo.getRewardTrait()).append("\n"); + } + + // ITEM + if (rewardInfo.getGainedItemDefId() != null && !rewardInfo.getGainedItemDefId().isEmpty()) { + sb.append("아이템 획득: ").append(rewardInfo.getGainedItemDefId()).append("\n"); + } + if (rewardInfo.getLostItemsDefId() != null && !rewardInfo.getLostItemsDefId().isEmpty()) { + sb.append("아이템 잃음: ").append(rewardInfo.getLostItemsDefId()).append("\n"); + } + + return sb.length() == 0 ? "보상이 없습니다." : sb.toString().trim(); + } + + private static String formatChange(int value) { + return value > 0 ? "+" + value : String.valueOf(value); + } + } From 1d0dde54e860ca13a921888e8601aa2d70c87d54 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 16:14:40 +0900 Subject: [PATCH 368/527] refactor: add response to FAST API --- .../com/scriptopia/demo/domain/mongo/GameSessionMongo.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java index 4bf95b83..470892be 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/GameSessionMongo.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.domain.mongo; +import com.scriptopia.demo.domain.RewardType; import com.scriptopia.demo.domain.SceneType; import lombok.*; import org.springframework.data.annotation.Id; @@ -27,7 +28,7 @@ public class GameSessionMongo { private String background; private String preChoice; - private String preReward; + private RewardType preReward; private String location; private Integer progress; private List stage; From d6511e979ee70ab7b7b765e0ed1437fb6685fbbb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 16:15:56 +0900 Subject: [PATCH 369/527] wip: nothing to change --- .../scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java index 8708a26a..7efe257c 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameDoneRequest.java @@ -20,6 +20,6 @@ public class CreateGameDoneRequest { private String resultContent; private String playerName; - private String playerVictory; + private boolean playerVictory; } \ No newline at end of file From 7af1ec7829fc96924a12a4b4bffbf6902c14ab09 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 16:16:37 +0900 Subject: [PATCH 370/527] rfactor: request FAST API to done endPoint --- .../demo/service/GameSessionService.java | 95 ++++++++++++++----- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d359528e..f976f08e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -389,7 +389,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { ChoiceMongo choiceMongo = ChoiceMongo.builder() .detail(choice.getDetail()) .stats(statInfo.get(i)) - .probability(null) + .probability(GameBalanceUtil.getChoiceProbability(statInfo.get(i), gameSessionMongo.getPlayerInfo())) .resultType(ChoiceResultType.nextResultType()) .rewardType(RewardType.getRandomRewardType()) .build(); @@ -405,29 +405,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { gameSessionMongo.setChoiceInfo(choiceInfoMongo); - HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); - - /** - * 이 부분 저장하는 것은 해당 DONE 이 나올 때 요약을 사용해야 함 **여기가 아님** - */ - if (currentEventStage > 0) { - switch (currentEventStage) { - case 1: - historyInfoMongo.setEpilogue1Title(createGameChoiceResponse.getChoiceInfo().getTitle()); - historyInfoMongo.setEpilogue1Content(createGameChoiceResponse.getChoiceInfo().getStory()); - break; - case 3: - historyInfoMongo.setEpilogue2Title(createGameChoiceResponse.getChoiceInfo().getTitle()); - historyInfoMongo.setEpilogue2Content(createGameChoiceResponse.getChoiceInfo().getStory()); - break; - case 5: - historyInfoMongo.setEpilogue3Title(createGameChoiceResponse.getChoiceInfo().getTitle()); - historyInfoMongo.setEpilogue3Content(createGameChoiceResponse.getChoiceInfo().getStory()); - break; - } - } - gameSessionMongo.setHistoryInfo(historyInfoMongo); gameSessionMongoRepository.save(gameSessionMongo); return gameSessionMongo; @@ -543,6 +521,77 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { } + @Transactional + public GameSessionMongo mapToCreateGameDoneRequest(Long userId) { + if (!gameSessionRepository.existsByUserId(userId)) { + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + String gameId = gameSession.getMongoId(); + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + CreateGameDoneRequest fastApiRequest = CreateGameDoneRequest.builder() + .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) + .location(gameSessionMongo.getLocation()) + .previousStory(gameSessionMongo.getBackground()) + .selectedChoice(gameSessionMongo.getPreChoice()) + .resultContent(RewardType.getRewardSummary(gameSessionMongo.getRewardInfo())) + .playerName(gameSessionMongo.getPlayerInfo().getName()) + .playerVictory( (gameSessionMongo.getSceneType() == SceneType.BATTLE)) + .build(); + + + CreateGameDoneResponse fastApiResponse = fastApiService.done(fastApiRequest); + + + if (fastApiResponse == null) { + throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); + } + + + gameSessionMongo.setSceneType(SceneType.DONE); + gameSessionMongo.setUpdatedAt(LocalDateTime.now()); + gameSessionMongo.setLocation(fastApiResponse.getDoneInfo().getNewLocation()); + gameSessionMongo.setBackground(fastApiResponse.getDoneInfo().getReCap()); + gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); + + + int currentProgress = gameSessionMongo.getProgress(); + List stage = gameSessionMongo.getStage(); + int currentEventStage = stage.get(currentProgress); + HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); + + if (currentEventStage > 0) { + switch (currentEventStage) { + case 2: + historyInfoMongo.setEpilogue1Content(fastApiResponse.getDoneInfo().getReCap()); + break; + case 4: + historyInfoMongo.setEpilogue2Content(fastApiResponse.getDoneInfo().getReCap()); + break; + case 6: + historyInfoMongo.setEpilogue3Content(fastApiResponse.getDoneInfo().getReCap()); + break; + } + } + + + gameSessionMongo.setHistoryInfo(historyInfoMongo); + gameSessionMongoRepository.save(gameSessionMongo); + + return gameSessionMongo; + } + + + + + /** * battle에서 사용 * item -> request로 쉽게 매필 From 1a825689e3df9eb5a2fd781631cb382352b50c67 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:28:15 +0900 Subject: [PATCH 371/527] feat: move piashop API access control to Spring Security --- .../demo/config/SecurityConfig.java | 2 -- .../demo/config/SecurityWhitelist.java | 4 ++-- .../demo/controller/PiaShopController.java | 20 ++++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 07a61406..af8f32d8 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -51,8 +51,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(SecurityWhitelist.AUTH_WHITELIST).permitAll() //public 권한(GET 요청) .requestMatchers(HttpMethod.GET,SecurityWhitelist.PUBLIC_GETS).permitAll() - //admin 권한 - .requestMatchers("/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index 708daf58..d800e824 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -4,14 +4,14 @@ public class SecurityWhitelist { public static final String[] AUTH_WHITELIST = { "/error", + "/auth/logout", "/auth/login", "/auth/register", "/auth/email/**", "/auth/password/reset/**", - "/oauth/**" - + "/oauth/**" }; diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 375b9125..06afb161 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -14,35 +15,36 @@ @RestController @RequiredArgsConstructor +@RequestMapping("/shops") public class PiaShopController { private final PiaShopService piaShopService; - // admin → public 으로 테스트용 변경했음 나중에 수정 바람 - @PostMapping("/public/shops/items/pia") + @PreAuthorize("hasAnyAuthority('ADMIN')") + @PostMapping("/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { piaShopService.createPiaItem(request); return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); } - // admin → public 으로 테스트용 변경했음 나중에 수정 바람 - @PutMapping("/public/shops/items/pia/{itemId}") + @PreAuthorize("hasAnyAuthority('ADMIN')") + @PutMapping("/items/pia/{itemId}") public ResponseEntity updatePiaItem( - @PathVariable String itemId, + @PathVariable("itemId") String itemId, @RequestBody PiaItemUpdateRequest requestDto) { String result = piaShopService.updatePiaItem(itemId, requestDto); return ResponseEntity.ok(result); } - - @GetMapping("/user/shops/pia/items") + @PreAuthorize("hasAnyAuthority('USER')") + @GetMapping("/pia/items") public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); } - - @PostMapping("/user/shops/pia/item/purchase") + @PreAuthorize("hasAnyAuthority('USER')") + @PostMapping("/pia/item/purchase") public ResponseEntity purchasePiaItem( @RequestBody PurchasePiaItemRequest requestDto, Authentication authentication) { From 47a44f7ac240859dbba028fa108e59fcf3123600 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:09:39 +0900 Subject: [PATCH 372/527] feat: add role-based access control with @PreAuthorize --- .../com/scriptopia/demo/controller/AuctionController.java | 8 +++++++- .../com/scriptopia/demo/controller/AuthController.java | 2 +- .../com/scriptopia/demo/controller/PiaShopController.java | 4 ++-- .../com/scriptopia/demo/controller/refreshController.java | 3 ++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 75a197f6..47292400 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -5,6 +5,7 @@ import com.scriptopia.demo.service.AuctionService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -24,6 +25,7 @@ public ResponseEntity getTrades( } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{auctionId}/purchase") public ResponseEntity purchaseItem( @PathVariable String auctionId, @@ -35,6 +37,7 @@ public ResponseEntity purchaseItem( return ResponseEntity.ok(result); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/me") public ResponseEntity mySaleItems( @RequestBody MySaleItemRequest requestDto, @@ -46,6 +49,7 @@ public ResponseEntity mySaleItems( return ResponseEntity.ok(result); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping public ResponseEntity createAuction(@RequestBody AuctionRequest dto, Authentication authentication ){ @@ -54,6 +58,7 @@ public ResponseEntity createAuction(@RequestBody AuctionRequest dto, return ResponseEntity.ok(auctionService.createAuction(dto, userId)); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @DeleteMapping("/{auctionId}") public ResponseEntity cancelMySaleItem( @PathVariable String auctionId, @@ -64,7 +69,7 @@ public ResponseEntity cancelMySaleItem( return ResponseEntity.ok(result); } - + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/me/history") public ResponseEntity settlementHistory( @RequestBody SettlementHistoryRequest requestDto, @@ -76,6 +81,7 @@ public ResponseEntity settlementHistory( return ResponseEntity.ok(result); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PatchMapping("/{settlementId}/confirm") public ResponseEntity confirmItem( @PathVariable String settlementId, diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 14a81c1e..d0e37395 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -103,7 +103,7 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest } - @PreAuthorize("hasAnyAuthority('USER')") + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PatchMapping("/password/change") public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, Authentication authentication) { diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 06afb161..3759758d 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -37,13 +37,13 @@ public ResponseEntity updatePiaItem( String result = piaShopService.updatePiaItem(itemId, requestDto); return ResponseEntity.ok(result); } - @PreAuthorize("hasAnyAuthority('USER')") + @GetMapping("/pia/items") public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); } - @PreAuthorize("hasAnyAuthority('USER')") + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/pia/item/purchase") public ResponseEntity purchasePiaItem( @RequestBody PurchasePiaItemRequest requestDto, diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java index 027e021f..e5c7f500 100644 --- a/src/main/java/com/scriptopia/demo/controller/refreshController.java +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -11,6 +11,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.time.Duration; @@ -30,7 +31,7 @@ public class refreshController { private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; - // 쿠키 기반 리프레시 + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/refresh") public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, From 952c8fb78ba9453d75132e3f9f4d1c05f55121e9 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:10:17 +0900 Subject: [PATCH 373/527] feat:add admin info --- src/main/resources/application.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e4e63124..8d8737c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,3 +61,8 @@ auth: access-exp-seconds: 1800 refresh-exp-seconds: 1209600 secret: ${JWT_SECRET} + +app: + admin: + username: ${ADMIN_NAME} + password: ${ADMIN_PASSWORD} \ No newline at end of file From e74f5ce62e7489c1991e723c9b6b909b1da8b49d Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:10:44 +0900 Subject: [PATCH 374/527] feat: add AdminInitioalizer for create admin account --- .../demo/config/AdminInitializer.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/AdminInitializer.java diff --git a/src/main/java/com/scriptopia/demo/config/AdminInitializer.java b/src/main/java/com/scriptopia/demo/config/AdminInitializer.java new file mode 100644 index 00000000..0b1b35fd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/AdminInitializer.java @@ -0,0 +1,63 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.repository.LocalAccountRepository; +import com.scriptopia.demo.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.cglib.core.Local; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class AdminInitializer implements ApplicationRunner { + + private final UserRepository userRepository; + private final LocalAccountRepository localAccountRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${app.admin.username}") + private String adminUsername; + + @Value("${app.admin.password}") + private String adminPassword; + + + + + @Override + public void run(ApplicationArguments args) { + if (!userRepository.existsByNickname("admin")) { + + User admin = new User(); + admin.setPia(999999L); + admin.setNickname("admin"); + admin.setCreatedAt(LocalDateTime.now()); + admin.setLastLoginAt(LocalDateTime.now()); + admin.setProfileImgUrl(null); + admin.setRole(Role.ADMIN); + admin.setLoginType(LoginType.LOCAL); + + User adminUser = userRepository.save(admin); + + + LocalAccount localAccount = new LocalAccount(); + localAccount.setUser(adminUser); + localAccount.setEmail(adminUsername); + localAccount.setPassword(passwordEncoder.encode(adminPassword)); + localAccount.setUpdatedAt(LocalDateTime.now()); + localAccount.setStatus(UserStatus.VERIFIED); + + LocalAccount adminAccount = localAccountRepository.save(localAccount); + + + } + } +} \ No newline at end of file From 395fc8f7d639933e66f38f060a8513f21a89d3f7 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:12:09 +0900 Subject: [PATCH 375/527] feat: handle AccessDeniedException globally --- .../scriptopia/demo/exception/GlobalExceptionHandler.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index a61e62af..9121ec72 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import com.scriptopia.demo.dto.exception.ErrorResponse; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -55,6 +56,13 @@ else if ("nickname".equals(fieldError.getField())){ .body(new ErrorResponse(errorCode)); } + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity + .status(ErrorCode.E_403.getStatus()) + .body(new ErrorResponse(ErrorCode.E_403)); + } + @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(final CustomException e) { ErrorCode errorCode = e.getErrorCode(); From 9a3d05ad451a82eda880d8ee0db8f89e429cb2bc Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:13:14 +0900 Subject: [PATCH 376/527] fix: use current user role instead of hardcoded USER in JWT --- .../java/com/scriptopia/demo/service/LocalAccountService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 1d3cd1e9..acfefda2 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -191,7 +191,7 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer User user = localAccount.getUser(); user.setLastLoginAt(LocalDateTime.now()); - List roles = List.of(Role.USER.toString()); + List roles = List.of(user.getRole().toString()); String access = jwt.createAccessToken(user.getId(), roles); String refresh = jwt.createRefreshToken(user.getId(), req.getDeviceId()); From ec6728e84d47a835b7fe46a1bf96d12b8438bd37 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:28:55 +0900 Subject: [PATCH 377/527] feat: add piaShop endpoint in whiteList --- src/main/java/com/scriptopia/demo/config/SecurityConfig.java | 3 +-- .../java/com/scriptopia/demo/config/SecurityWhitelist.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index af8f32d8..17071c0f 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -37,8 +37,6 @@ public PasswordEncoder passwordEncoder() { private final JwtAuthFilter jwtAuthFilter; - - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -51,6 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(SecurityWhitelist.AUTH_WHITELIST).permitAll() //public 권한(GET 요청) .requestMatchers(HttpMethod.GET,SecurityWhitelist.PUBLIC_GETS).permitAll() + .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index d800e824..6cadb5a9 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -11,8 +11,9 @@ public class SecurityWhitelist { "/auth/email/**", "/auth/password/reset/**", - "/oauth/**" + "/oauth/**", + "/shops/pia/items" }; public static final String[] PUBLIC_GETS = { From 06f742df70a3576ee8aa19f9c12e0d6acfa20080 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:03:42 +0900 Subject: [PATCH 378/527] wip: working on auth/authorization in SharedGameController --- .../demo/config/SecurityWhitelist.java | 5 +++- .../demo/controller/SearchController.java | 22 --------------- ...troller.java => SharedGameController.java} | 27 +++++++++++++------ 3 files changed, 23 insertions(+), 31 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/controller/SearchController.java rename src/main/java/com/scriptopia/demo/controller/{PublicSharedGameController.java => SharedGameController.java} (53%) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index 6cadb5a9..7128cc72 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -14,9 +14,12 @@ public class SecurityWhitelist { "/oauth/**", "/shops/pia/items" + + }; public static final String[] PUBLIC_GETS = { - "/trades" + "/trades", + "/shared-games/**" }; } diff --git a/src/main/java/com/scriptopia/demo/controller/SearchController.java b/src/main/java/com/scriptopia/demo/controller/SearchController.java deleted file mode 100644 index ceb3ce8d..00000000 --- a/src/main/java/com/scriptopia/demo/controller/SearchController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.service.SharedGameFavoriteService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class SearchController { - private final SharedGameFavoriteService sharedGameFavoriteService; - - @PostMapping("/users/games/shared/{sharedGameId}/like") - public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); - } -} diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java similarity index 53% rename from src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java rename to src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 341fbc82..a50968bf 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; +import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,13 +12,14 @@ import java.util.List; @RestController -@RequestMapping("/public/games/shared") +@RequestMapping("/shared-games") @RequiredArgsConstructor -public class PublicSharedGameController { +public class SharedGameController { private final SharedGameService sharedGameService; + private final SharedGameFavoriteService sharedGameFavoriteService; @GetMapping("/{sharedGameId}") - public ResponseEntity getSharedGameDetail(@PathVariable Long sharedGameId) { + public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") Long sharedGameId) { return sharedGameService.getDetailedSharedGame(sharedGameId); } @@ -27,12 +29,21 @@ public ResponseEntity getSharedGameTags() { } @GetMapping - public ResponseEntity> getPublicSharedGames(Authentication authentication, - @RequestParam(required = false) Long lastId, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) List tagIds, - @RequestParam(required = false) String q) { + public ResponseEntity> getPublicSharedGames( + Authentication authentication, + @RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) List tagIds, + @RequestParam(required = false) String q) { Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, q); } + + @PostMapping("/{sharedGameId}/like") + public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") Long sharedGameId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); + } + + } From 633ef6bf52cb862468a55659b2194cb630b4c03e Mon Sep 17 00:00:00 2001 From: KII1ua Date: Thu, 11 Sep 2025 18:13:20 +0900 Subject: [PATCH 379/527] feature/126-sharedgame-uuid --- .../demo/controller/PublicSharedGameController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java index d9cab3aa..9a19195b 100644 --- a/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/PublicSharedGameController.java @@ -41,8 +41,8 @@ public ResponseEntity> getPublicSharedGames @RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) List tagIds, - @RequestParam(required = false) String q) { + @RequestParam(required = false) String query) { Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); - return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, q); + return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, query); } } From 439dd31aaee5429cb2497a4a3a7d8d89b78d075f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:05:47 +0900 Subject: [PATCH 380/527] feat: improve game session battle endpoint logic --- .../controller/GameSessionController.java | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 15a5e048..2cd17d0a 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -47,40 +47,26 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } - /** - * 테스트 중 - */ - @PostMapping("/testC") - public ResponseEntity testCoGame( - Authentication authentication) throws JsonProcessingException { - - Long userId = Long.valueOf(authentication.getName()); - GameSessionMongo response = gameSessionService.mapToCreateGameChoiceRequest(userId); - return ResponseEntity.ok(response); - } - - /** - * 테스트 전투 - */ - @PostMapping("/testB") - public ResponseEntity testBaGame( + @PostMapping("/progress") + public ResponseEntity keepGame( Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - CreateGameBattleResponse response = gameSessionService.mapToCreateGameBattleRequest(userId); + GameSessionMongo response = gameSessionService.gameProgress(userId); return ResponseEntity.ok(response); } @PostMapping("/test") - public ResponseEntity testGame( + public ResponseEntity choiceSelectGame( + @RequestBody GameChoiceRequest request, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - GameSessionMongo response = gameSessionService.mapToCreateGameDoneRequest(userId); + GameSessionMongo response = gameSessionService.gameChoiceSelect(userId, request); return ResponseEntity.ok(response); } From a6e20d6d6305971f21570e5c4143bbf875c47b0d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:06:11 +0900 Subject: [PATCH 381/527] feat: enhance item controller for equip and retrieval functions --- .../java/com/scriptopia/demo/controller/ItemController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index c45db1c7..1ee7cfe3 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -16,8 +16,8 @@ public class ItemController { @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest request) { - ItemFastApiResponse savedItem = itemDefService.createItem(request); + public ResponseEntity createItem(@RequestBody ItemDefRequest request) { + String savedItem = itemDefService.createItem(request); return ResponseEntity.ok(savedItem); } From 5e3788ccb30c88b49c43a051c0b842a11d8e4bd0 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:06:53 +0900 Subject: [PATCH 382/527] feat: add new choice result type --- .../java/com/scriptopia/demo/domain/ChoiceResultType.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index f4a4bdff..53e265ff 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -4,12 +4,13 @@ import java.security.SecureRandom; + @Getter public enum ChoiceResultType { BATTLE(40), CHOICE(30), SHOP(10), - NONE(30); + DONE(30); private final int nextEventType; @@ -30,6 +31,6 @@ public static ChoiceResultType nextResultType() { return type; } } - return NONE; + return DONE; } } From e0b4c65835e2f7acd2cc8a484373b367f23b1e08 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:07:26 +0900 Subject: [PATCH 383/527] fix: fix battle result update issue in GameSessionService --- .../demo/service/GameSessionService.java | 185 +++++++++++++++++- 1 file changed, 179 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f976f08e..f514ff27 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,8 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.config.fastapi.FastApiClient; +import com.scriptopia.demo.dto.items.ItemDefRequest; +import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; import com.scriptopia.demo.utils.GameBalanceUtil; @@ -20,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.awt.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -35,6 +38,7 @@ public class GameSessionService { private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; private final FastApiService fastApiService; + private final ItemDefService itemDefService; public boolean duplcatedGameSession(Long userId) { @@ -258,6 +262,8 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { userItemRepository.save(userItem); } + gameToChoice(userId); + // MongoDB PK 반환 return new StartGameResponse( "게임이 생성되었습니다.", @@ -266,8 +272,50 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { } + /** + * 게임 진행 + * @param userId + */ @Transactional - public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { + public GameSessionMongo gameProgress(Long userId) { + + if (!gameSessionRepository.existsByUserId(userId)) { + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + String gameId = gameSession.getMongoId(); + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + SceneType currentSceneType = gameSessionMongo.getSceneType(); + switch (currentSceneType) { + case SceneType.CHOICE -> { + gameToChoice(userId); + } + case SceneType.BATTLE -> { + gameToDone(userId); + } + case SceneType.DONE -> { + gameToChoice(userId); + } + default -> throw new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND); + } + + return gameSessionMongo; + } + + /** + * 선택지 생성 + * @param userId + */ + @Transactional + public GameSessionMongo gameToChoice(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); @@ -351,7 +399,7 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { }).toList(); fastApiRequest.setItemInfo(itemInfoList); - + System.out.println("asdasd " + fastApiRequest); CreateGameChoiceResponse createGameChoiceResponse = fastApiService.makeChoice(fastApiRequest); if (createGameChoiceResponse == null) { @@ -397,6 +445,17 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { choiceList.add(choiceMongo); } + + ChoiceMongo promptChoice = ChoiceMongo.builder() + .detail(null) + .stats(null) + .probability(null) + .resultType(ChoiceResultType.nextResultType()) + .rewardType(RewardType.getRandomRewardType()) + .build(); + choiceList.add(promptChoice); + + ChoiceInfoMongo choiceInfoMongo = ChoiceInfoMongo.builder() .eventType(fastApiRequest.getEventType()) .story(createGameChoiceResponse.getChoiceInfo().getStory()) @@ -411,9 +470,12 @@ public GameSessionMongo mapToCreateGameChoiceRequest(Long userId) { return gameSessionMongo; } - + /** + * @param userId + * @return win? + */ @Transactional - public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { + public int gameToBattle(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } @@ -517,12 +579,12 @@ public CreateGameBattleResponse mapToCreateGameBattleRequest(Long userId) { gameSessionMongoRepository.save(gameSessionMongo); - return fastApiResponse; + return playerWin; } @Transactional - public GameSessionMongo mapToCreateGameDoneRequest(Long userId) { + public GameSessionMongo gameToDone(Long userId) { if (!gameSessionRepository.existsByUserId(userId)) { throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); } @@ -581,6 +643,11 @@ public GameSessionMongo mapToCreateGameDoneRequest(Long userId) { } } + /** + * + * reward가 있으면 그대로 현재를 갱신한 후 mongoDB 저장 + */ + gameSessionMongo.setHistoryInfo(historyInfoMongo); gameSessionMongoRepository.save(gameSessionMongo); @@ -590,6 +657,62 @@ public GameSessionMongo mapToCreateGameDoneRequest(Long userId) { + @Transactional + public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) { + if (!gameSessionRepository.existsByUserId(userId)) { + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + String gameId = gameSession.getMongoId(); + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + Stat userChoiceStat = null; + Integer probability = null; + + Integer choiceIndex = request.getChoiceIndex(); + choiceIndex = (choiceIndex == null) ? 3 : choiceIndex; + ChoiceMongo choiceMongo = gameSessionMongo.getChoiceInfo().getChoice().get(choiceIndex); + + if (request.getChoiceIndex() == null) { + // 사용자 프롬프트 입력 → FAST API 호출해야 함 + } else { + userChoiceStat = choiceMongo.getStats(); + probability = GameBalanceUtil.getChoiceProbability(userChoiceStat, gameSessionMongo.getPlayerInfo()); + } + + RewardType rewardType = choiceMongo.getRewardType(); + ChoiceResultType nextScene = choiceMongo.getResultType(); + boolean isPass = GameBalanceUtil.isPass(probability); + + RewardInfoMongo rewardInfo; + + switch (nextScene) { + case CHOICE -> { + // 보상 없음 → 그냥 다음 Choice 리턴 + return gameToChoice(userId); + } + case BATTLE -> { + isPass = (gameToBattle(userId) == 1); + gameSessionMongo = gameSessionMongoRepository.findById(gameId).get(); + rewardInfo = handleReward(gameSessionMongo, rewardType, isPass); + } + case DONE -> { + gameToDone(userId); + + gameSessionMongo = gameSessionMongoRepository.findById(gameId).get(); + rewardInfo = handleReward(gameSessionMongo, rewardType, isPass); + } + default -> throw new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND); + } + + // 보상 저장 + gameSessionMongo.setRewardInfo(rewardInfo); + return gameSessionMongoRepository.save(gameSessionMongo); + } /** @@ -611,4 +734,54 @@ private CreateGameBattleRequest.Item mapToItemEffect(ItemDefMongo item) { .effects(effects) .build(); } + + + private ItemDefMongo convertToItemDefMongo(ItemFastApiResponse response) { + List effects = new ArrayList<>(); + + if (response.getItemEffect() != null) { + for (ItemFastApiResponse.ItemEffect e : response.getItemEffect()) { + ItemEffectMongo effectMongo = ItemEffectMongo.builder() + .itemEffectName(e.getItemEffectName()) + .itemEffectDescription(e.getItemEffectDescription()) + .build(); + + effects.add(effectMongo); + } + } + + return ItemDefMongo.builder() + .name(response.getItemName()) + .description(response.getItemDescription()) + .itemEffect(effects) + .build(); + } + + + /** + * 보상 처리 (ITEM 포함) + */ + private RewardInfoMongo handleReward(GameSessionMongo gameSessionMongo, RewardType rewardType, boolean isPass) { + RewardInfoMongo rewardInfo = GameBalanceUtil.getReward(rewardType, isPass); + + if (rewardType == RewardType.ITEM && isPass) { + ItemDefRequest itemDefRequest = ItemDefRequest.builder() + .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) + .location(gameSessionMongo.getLocation()) + .playerTrait(null) + .previousStory(gameSessionMongo.getBackground()) + .build(); + + String itemMongoId = itemDefService.createItem(itemDefRequest); + InventoryMongo newItem = InventoryMongo.builder() + .acquiredAt(LocalDateTime.now()) + .ItemDefId(itemMongoId) + .source("Scenario") + .equipped(false) + .build(); + + gameSessionMongo.getInventory().add(newItem); + } + return rewardInfo; + } } From 1e7d7c2d28c73598e68cd7416669ddfa553d553e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:07:58 +0900 Subject: [PATCH 384/527] fix: fix equipped item retrieval issue in ItemDefService --- .../com/scriptopia/demo/service/ItemDefService.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemDefService.java index 944ef091..79ecdac7 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemDefService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemDefService.java @@ -35,8 +35,13 @@ public class ItemDefService { private final FastApiService fastApiService; + /** + * mongoDB, RDB에 저장 후 mongoDB의 item_Def_id를 리턴 + * @param request + * @return + */ @Transactional(readOnly = false) - public ItemFastApiResponse createItem(ItemDefRequest request) { + public String createItem(ItemDefRequest request) { /** * 1. 카테고리 * 2. 등급 @@ -72,8 +77,6 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { - - ItemFastApiRequest fastRequest = ItemFastApiRequest.builder() .worldView(request.getWorldView()) .location(request.getLocation()) @@ -159,8 +162,7 @@ public ItemFastApiResponse createItem(ItemDefRequest request) { itemDefRepository.save(itemDefRdb); - return response; - + return itemDefMongo.getId(); } From 116b36b7ac0aaa090a8b50f12395f49d22aa4dfd Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 11 Sep 2025 22:08:26 +0900 Subject: [PATCH 385/527] refactor: optimize and refactor GameBalanceUtil code --- .../demo/utils/GameBalanceUtil.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 30450686..7291eab4 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -2,10 +2,12 @@ import com.scriptopia.demo.domain.EffectProbability; import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.RewardType; import com.scriptopia.demo.domain.Stat; import com.scriptopia.demo.domain.mongo.ItemDefMongo; import com.scriptopia.demo.domain.mongo.ItemEffectMongo; import com.scriptopia.demo.domain.mongo.PlayerInfoMongo; +import com.scriptopia.demo.domain.mongo.RewardInfoMongo; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.EffectGradeDefRepository; @@ -346,4 +348,45 @@ public static int getNpcHealthPoint(int npcRank) { double variance = 0.9 + secureRandom.nextDouble() * 0.2; // 0.9 ~ 1.1 return (int) Math.round(base * variance); } + + + public static boolean isPass(int choiceProbability) { + int randomCount = secureRandom.nextInt(100) + 1; + + return (choiceProbability >= randomCount); + } + + public static RewardInfoMongo getReward(RewardType rewardType, boolean isPass) { + RewardInfoMongo.RewardInfoMongoBuilder builder = RewardInfoMongo.builder(); + + // 기본값: 성공이면 생명 +1, 실패면 생명 -1 + builder.rewardLife(isPass ? 1 : -1); + + switch (rewardType) { + case GOLD: + int gold = secureRandom.nextInt(50) + 1; // 1~50 골드 + builder.rewardGold(isPass ? gold : -gold); + break; + + case STAT: + int statValue = secureRandom.nextInt(3) + 1; // 1~3 사이 스탯 변화 + int statType = secureRandom.nextInt(4); // 0~3 → 힘, 민첩, 지능, 운 중 하나 + switch (statType) { + case 0 -> builder.rewardStrength(isPass ? statValue : -statValue); + case 1 -> builder.rewardAgility(isPass ? statValue : -statValue); + case 2 -> builder.rewardIntelligence(isPass ? statValue : -statValue); + case 3 -> builder.rewardLuck(isPass ? statValue : -statValue); + } + break; + case ITEM: + case NONE: + default: + // 아무 보상 없음 + break; + } + + return builder.build(); + } + + } From 3baa27eaec2a65f4533f8b82dc84da618ce05fc0 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 12 Sep 2025 22:48:38 +0900 Subject: [PATCH 386/527] feature/143-gamesession --- .../com/scriptopia/demo/controller/GameSessionController.java | 4 ++++ .../com/scriptopia/demo/controller/SharedGameController.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 7b27000b..b66f23e8 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -9,6 +9,7 @@ import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -19,6 +20,7 @@ public class GameSessionController { private final GameSessionService gameSessionService; private final HistoryService historyService; + @PreAuthorize("hasAnyAuthority('USER')") @PostMapping("/{gameId}/exit") public ResponseEntity createGameSession(Authentication authentication, @PathVariable String gameId) { // 게임 세션 정보 저장 @@ -27,6 +29,7 @@ public ResponseEntity createGameSession(Authentication authentication, @PathV return gameSessionService.saveGameSession(userId, gameId); } + @PreAuthorize("hasAnyAuthority('USER')") @DeleteMapping("/{gameId}") public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { Long userId = Long.valueOf(authentication.getName()); @@ -64,6 +67,7 @@ public ResponseEntity testGame( * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ + @PreAuthorize("hasAnyAuthority('USER')") @PostMapping("/{gameId}/history") public ResponseEntity addHistory(@PathVariable String gameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 9a19195b..c9936698 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -14,7 +14,7 @@ @RestController @RequestMapping("/games/shared") @RequiredArgsConstructor -public class PublicSharedGameController { +public class SharedGameController { private final SharedGameService sharedGameService; /* From 32fa8c43d4c07156145a187d10f29bcaa8a8c73e Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:49:18 +0900 Subject: [PATCH 387/527] feat: add attribute wordSpacing in userSetting Entity --- .../scriptopia/demo/config/AdminInitializer.java | 13 +++++++++++-- .../scriptopia/demo/controller/AuthController.java | 3 --- .../demo/controller/SharedGameController.java | 2 +- .../demo/service/LocalAccountService.java | 6 +++++- src/main/resources/application.yml | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/AdminInitializer.java b/src/main/java/com/scriptopia/demo/config/AdminInitializer.java index 0b1b35fd..c18b1650 100644 --- a/src/main/java/com/scriptopia/demo/config/AdminInitializer.java +++ b/src/main/java/com/scriptopia/demo/config/AdminInitializer.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.domain.*; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; @@ -22,6 +23,7 @@ public class AdminInitializer implements ApplicationRunner { private final UserRepository userRepository; private final LocalAccountRepository localAccountRepository; private final PasswordEncoder passwordEncoder; + private final UserSettingRepository userSettingRepository; @Value("${app.admin.username}") private String adminUsername; @@ -44,7 +46,6 @@ public void run(ApplicationArguments args) { admin.setProfileImgUrl(null); admin.setRole(Role.ADMIN); admin.setLoginType(LoginType.LOCAL); - User adminUser = userRepository.save(admin); @@ -54,9 +55,17 @@ public void run(ApplicationArguments args) { localAccount.setPassword(passwordEncoder.encode(adminPassword)); localAccount.setUpdatedAt(LocalDateTime.now()); localAccount.setStatus(UserStatus.VERIFIED); - LocalAccount adminAccount = localAccountRepository.save(localAccount); + UserSetting userSetting = new UserSetting(); + userSetting.setUser(adminUser); + userSetting.setTheme(Theme.DARK); + userSetting.setFontType(FontType.PretendardVariable); + userSetting.setFontSize(16); + userSetting.setLineHeight(1); + userSetting.setWordSpacing(1); + userSetting.setUpdatedAt(LocalDateTime.now()); + userSettingRepository.save(userSetting); } } diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index d0e37395..4cd7f94d 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,16 +1,13 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.dto.localaccount.*; import com.scriptopia.demo.service.LocalAccountService; -import com.scriptopia.demo.utils.JwtProvider; import com.scriptopia.demo.service.RefreshTokenService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 9a19195b..c9936698 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -14,7 +14,7 @@ @RestController @RequestMapping("/games/shared") @RequiredArgsConstructor -public class PublicSharedGameController { +public class SharedGameController { private final SharedGameService sharedGameService; /* diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index acfefda2..926d344a 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.LocalAccountRepository; import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.repository.UserSettingRepository; import com.scriptopia.demo.utils.JwtProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -50,7 +51,7 @@ public class LocalAccountService { private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); private static final long TOKEN_EXPIRATION = 30; - + private final UserSettingRepository userSettingRepository; @Transactional @@ -168,11 +169,14 @@ public void register(RegisterRequest request) { //환경 설정 초기 값 UserSetting userSetting = new UserSetting(); + userSetting.setUser(user); userSetting.setTheme(Theme.DARK); userSetting.setFontType(FontType.PretendardVariable); userSetting.setFontSize(16); userSetting.setLineHeight(1); + userSetting.setWordSpacing(1); userSetting.setUpdatedAt(LocalDateTime.now()); + userSettingRepository.save(userSetting); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d8737c3..70b92ee8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true From ed8f3f5a1223b0fdc865929f70a2bf41ef029085 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:49:55 +0900 Subject: [PATCH 388/527] feat: add GetSettingsResponse dto --- .../demo/dto/users/GetSettingsResponse.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java b/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java new file mode 100644 index 00000000..08d9aab7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java @@ -0,0 +1,20 @@ +package com.scriptopia.demo.dto.users; + +import com.scriptopia.demo.domain.FontType; +import com.scriptopia.demo.domain.Theme; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class GetSettingsResponse { + private Theme theme; + private Integer fondSize; + private FontType font; + private Integer lineHeight; + private Integer wordSpacing; +} From d7cba537f0eccecaabdebf62ca11fad87c04b24a Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:50:28 +0900 Subject: [PATCH 389/527] feat: create method findByUserId --- .../com/scriptopia/demo/repository/UserSettingRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java index 9f36d364..9386e024 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface UserSettingRepository extends JpaRepository { + + UserSetting findByUserId(Long userId); } From b136972b185729941bef4120e5e0d4eeadeed523 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:51:14 +0900 Subject: [PATCH 390/527] feat: implement getSettingsByUser method --- .../scriptopia/demo/domain/UserSetting.java | 2 ++ .../scriptopia/demo/service/UserService.java | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/service/UserService.java diff --git a/src/main/java/com/scriptopia/demo/domain/UserSetting.java b/src/main/java/com/scriptopia/demo/domain/UserSetting.java index 8bc758db..fecc92de 100644 --- a/src/main/java/com/scriptopia/demo/domain/UserSetting.java +++ b/src/main/java/com/scriptopia/demo/domain/UserSetting.java @@ -25,5 +25,7 @@ public class UserSetting { private int lineHeight; + private int wordSpacing; + private LocalDateTime updatedAt; } diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java new file mode 100644 index 00000000..782de8aa --- /dev/null +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -0,0 +1,34 @@ +package com.scriptopia.demo.service; + +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserSetting; +import com.scriptopia.demo.dto.users.GetSettingsResponse; +import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.repository.UserSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserSettingRepository userSettingRepository; + + @Transactional + public GetSettingsResponse getSettings(String userId){ + + UserSetting userSetting = userSettingRepository.findByUserId(Long.valueOf(userId)); + + return GetSettingsResponse.builder() + .theme(userSetting.getTheme()) + .fondSize(userSetting.getFontSize()) + .font(userSetting.getFontType()) + .lineHeight(userSetting.getLineHeight()) + .wordSpacing(userSetting.getWordSpacing()) + .build(); + + } + +} From 6c88cc82f10a5ed668c9df0774d9f96498da7208 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:52:46 +0900 Subject: [PATCH 391/527] feat: create getUserSettings API endpoint : users/me/settings --- .../demo/controller/UserController.java | 29 +++++++++++++++++++ .../scriptopia/demo/service/UserService.java | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/controller/UserController.java diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java new file mode 100644 index 00000000..bd24522f --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -0,0 +1,29 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.users.GetSettingsResponse; +import com.scriptopia.demo.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/users/me") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @GetMapping("/settings") + public ResponseEntity getUserSettings( + Authentication authentication + ) { + String userId = authentication.getName(); + GetSettingsResponse response = userService.getUserSettings(userId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index 782de8aa..5865fd59 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -1,9 +1,7 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserSetting; import com.scriptopia.demo.dto.users.GetSettingsResponse; -import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,7 +15,7 @@ public class UserService { private final UserSettingRepository userSettingRepository; @Transactional - public GetSettingsResponse getSettings(String userId){ + public GetSettingsResponse getUserSettings(String userId){ UserSetting userSetting = userSettingRepository.findByUserId(Long.valueOf(userId)); From e93a2da438ffff0246868f758d24a1c827d8b1ee Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:17 +0900 Subject: [PATCH 392/527] Modify GameSessionController to handle new stage-based story flow and reward logic --- .../demo/controller/GameSessionController.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 2cd17d0a..d245f2e7 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -9,16 +9,18 @@ import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/users/games") +@RequestMapping("/games") @RequiredArgsConstructor public class GameSessionController { private final GameSessionService gameSessionService; private final HistoryService historyService; + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{gameId}/exit") public ResponseEntity createGameSession(Authentication authentication, @PathVariable String gameId) { // 게임 세션 정보 저장 @@ -27,6 +29,7 @@ public ResponseEntity createGameSession(Authentication authentication, @PathV return gameSessionService.saveGameSession(userId, gameId); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @DeleteMapping("/{gameId}") public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { Long userId = Long.valueOf(authentication.getName()); @@ -36,6 +39,7 @@ public ResponseEntity deleteGameSession(Authentication authentication, @PathV // 게임 시작 + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping public ResponseEntity startNewGame( @RequestBody StartGameRequest request, @@ -48,6 +52,7 @@ public ResponseEntity startNewGame( } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/progress") public ResponseEntity keepGame( Authentication authentication) throws JsonProcessingException { @@ -58,7 +63,7 @@ public ResponseEntity keepGame( return ResponseEntity.ok(response); } - + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/test") public ResponseEntity choiceSelectGame( @RequestBody GameChoiceRequest request, @@ -74,6 +79,7 @@ public ResponseEntity choiceSelectGame( * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{gameId}/history") public ResponseEntity addHistory(@PathVariable String gameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); @@ -82,6 +88,7 @@ public ResponseEntity addHistory(@PathVariable String gameId, Authentication } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/history/seed") public ResponseEntity seed(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); From ef1e29b595fc954a328e4590f0f5a65929aa81c6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:18 +0900 Subject: [PATCH 393/527] Update SharedGameController to integrate with updated game session APIs --- .../com/scriptopia/demo/controller/SharedGameController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 9a19195b..c9936698 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -14,7 +14,7 @@ @RestController @RequestMapping("/games/shared") @RequiredArgsConstructor -public class PublicSharedGameController { +public class SharedGameController { private final SharedGameService sharedGameService; /* From 5c187e50a33260c213d4ad6351b2536a3c0357c1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:18 +0900 Subject: [PATCH 394/527] Add or modify ChoiceResultType enum to support new game choice results --- .../demo/domain/ChoiceResultType.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index 53e265ff..e4c8f4a7 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -7,10 +7,10 @@ @Getter public enum ChoiceResultType { - BATTLE(40), - CHOICE(30), - SHOP(10), - DONE(30); + BATTLE(20), + CHOICE(40), + DONE(45), + SHOP(5); private final int nextEventType; @@ -21,8 +21,14 @@ public enum ChoiceResultType { } - public static ChoiceResultType nextResultType() { - int rand = random.nextInt(100) + 1; + public static ChoiceResultType nextResultType(ChoiceEventType nextEventType) { + int rand = 0; + if( nextEventType == ChoiceEventType.LIVING){ + rand = random.nextInt(100) + 1; // 1 ~ 100 + }else{ + rand = random.nextInt(60) + 41; // 41 ~ 100 + } + int cumulative = 0; for(ChoiceResultType type : values()) { From d2e724ddd8eeca9782407dfc50aa9743c303389a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:18 +0900 Subject: [PATCH 395/527] Modify InventoryMongo structure to support updated item tracking --- .../java/com/scriptopia/demo/domain/mongo/InventoryMongo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java index be86db4b..76dc68a1 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/InventoryMongo.java @@ -13,7 +13,7 @@ public class InventoryMongo { @Id private String id; - private String ItemDefId; + private String itemDefId; private LocalDateTime acquiredAt; private Boolean equipped; private String source; From 5100a7d68d9312288f2700ff0ef6d938b5f63ab9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:18 +0900 Subject: [PATCH 396/527] Update RewardInfoMongo to initialize gained item list properly --- .../com/scriptopia/demo/domain/mongo/RewardInfoMongo.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java index 4c6cde76..34de088e 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java @@ -9,8 +9,8 @@ @AllArgsConstructor @NoArgsConstructor public class RewardInfoMongo { - private List gainedItemDefId; - private List lostItemsDefId; + private List gainedItemDefId; + private List lostItemsDefId; private Integer rewardStrength; private Integer rewardAgility; private Integer rewardIntelligence; From 7ba246c79929d00ab93842bcbf15e15a8701abeb Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:19 +0900 Subject: [PATCH 397/527] Update CreateGameChoiceRequest DTO to match new API request structure --- .../demo/dto/gamesession/CreateGameChoiceRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java index be75b9b4..96c09658 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/CreateGameChoiceRequest.java @@ -15,7 +15,7 @@ public class CreateGameChoiceRequest { private String currentStory; private String location; private String currentChoice; - private List choiceStat; + private List choiceStat; private ChoiceEventType eventType; private Integer npcRank; private PlayerInfo playerInfo; From 0fcf20c212011f2be9c95a4f6cda0c4788b69222 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:19 +0900 Subject: [PATCH 398/527] Enhance GlobalExceptionHandler to catch potential NullPointerExceptions in game session service --- .../demo/exception/GlobalExceptionHandler.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 9121ec72..66852d77 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -79,11 +79,12 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { .body(new ErrorResponse(ErrorCode.E_401_REFRESH_EXPIRED)); } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse(ErrorCode.E_500)); - } +// @ExceptionHandler(Exception.class) +// public ResponseEntity handleGeneralException(Exception ex) { + +// return ResponseEntity +// .status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(new ErrorResponse(ErrorCode.E_500)); +// } } From 0933ee78d00d2522e9bcbb55a36a1a37cb867ff9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:19 +0900 Subject: [PATCH 399/527] Modify GameSessionService to fix reward handling and stage story assignment logic --- .../demo/service/GameSessionService.java | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f514ff27..6225e675 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -211,7 +211,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // Inventory Data List inventoryMongoList = new ArrayList<>(); inventoryMongoList.add(InventoryMongo.builder() - .ItemDefId(savedItemDefMongo.getId()) + .itemDefId(savedItemDefMongo.getId()) .acquiredAt(LocalDateTime.now()) .equipped(true) .source("StartWeapon") @@ -335,9 +335,9 @@ public GameSessionMongo gameToChoice(Long userId) { fastApiRequest.setLocation(gameSessionMongo.getLocation()); - List statInfo = new ArrayList<>(); + List statInfo = new ArrayList<>(); for (int i = 0; i < 3; i++) { - statInfo.add(Stat.getRandomMainStat()); + statInfo.add(Stat.getRandomMainStat().toString()); } @@ -436,9 +436,9 @@ public GameSessionMongo gameToChoice(Long userId) { ChoiceMongo choiceMongo = ChoiceMongo.builder() .detail(choice.getDetail()) - .stats(statInfo.get(i)) - .probability(GameBalanceUtil.getChoiceProbability(statInfo.get(i), gameSessionMongo.getPlayerInfo())) - .resultType(ChoiceResultType.nextResultType()) + .stats(Stat.valueOf(statInfo.get(i))) + .probability(GameBalanceUtil.getChoiceProbability(Stat.valueOf(statInfo.get(i)), gameSessionMongo.getPlayerInfo())) + .resultType(ChoiceResultType.nextResultType(currentEventType)) .rewardType(RewardType.getRandomRewardType()) .build(); @@ -450,7 +450,7 @@ public GameSessionMongo gameToChoice(Long userId) { .detail(null) .stats(null) .probability(null) - .resultType(ChoiceResultType.nextResultType()) + .resultType(ChoiceResultType.nextResultType(currentEventType)) .rewardType(RewardType.getRandomRewardType()) .build(); choiceList.add(promptChoice); @@ -631,12 +631,15 @@ public GameSessionMongo gameToDone(Long userId) { if (currentEventStage > 0) { switch (currentEventStage) { + case 1: case 2: historyInfoMongo.setEpilogue1Content(fastApiResponse.getDoneInfo().getReCap()); break; + case 3: case 4: historyInfoMongo.setEpilogue2Content(fastApiResponse.getDoneInfo().getReCap()); break; + case 5: case 6: historyInfoMongo.setEpilogue3Content(fastApiResponse.getDoneInfo().getReCap()); break; @@ -647,6 +650,19 @@ public GameSessionMongo gameToDone(Long userId) { * * reward가 있으면 그대로 현재를 갱신한 후 mongoDB 저장 */ + PlayerInfoMongo playerInfoMongo = gameSessionMongo.getPlayerInfo(); + RewardInfoMongo rewardInfoMongo = gameSessionMongo.getRewardInfo(); + List inventory = gameSessionMongo.getInventory(); + List inGameItem = gameSessionMongo.getCreatedItems(); + + + playerInfoMongo = GameBalanceUtil.updateReward(playerInfoMongo, rewardInfoMongo); + inventory = GameBalanceUtil.updateRewardItem(inventory, rewardInfoMongo); + inGameItem = GameBalanceUtil.updateInGameItem(inGameItem, rewardInfoMongo); + + gameSessionMongo.setPlayerInfo(playerInfoMongo); + gameSessionMongo.setInventory(inventory); + gameSessionMongo.setCreatedItems(inGameItem); gameSessionMongo.setHistoryInfo(historyInfoMongo); @@ -699,6 +715,8 @@ public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) isPass = (gameToBattle(userId) == 1); gameSessionMongo = gameSessionMongoRepository.findById(gameId).get(); rewardInfo = handleReward(gameSessionMongo, rewardType, isPass); + + } case DONE -> { gameToDone(userId); @@ -773,14 +791,12 @@ private RewardInfoMongo handleReward(GameSessionMongo gameSessionMongo, RewardTy .build(); String itemMongoId = itemDefService.createItem(itemDefRequest); - InventoryMongo newItem = InventoryMongo.builder() - .acquiredAt(LocalDateTime.now()) - .ItemDefId(itemMongoId) - .source("Scenario") - .equipped(false) - .build(); - - gameSessionMongo.getInventory().add(newItem); + List gainItemList = rewardInfo.getGainedItemDefId(); + if (gainItemList == null) { + gainItemList = new ArrayList<>(); // null이면 새 리스트 생성 + } + gainItemList.add(itemMongoId); + rewardInfo.setGainedItemDefId(gainItemList); } return rewardInfo; } From 987ee6a9eedb6d169e5a0a24122b338474bed774 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:01:20 +0900 Subject: [PATCH 400/527] Update GameBalanceUtil to adjust reward calculation logic --- .../demo/utils/GameBalanceUtil.java | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 7291eab4..03a79c9d 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -4,10 +4,7 @@ import com.scriptopia.demo.domain.Grade; import com.scriptopia.demo.domain.RewardType; import com.scriptopia.demo.domain.Stat; -import com.scriptopia.demo.domain.mongo.ItemDefMongo; -import com.scriptopia.demo.domain.mongo.ItemEffectMongo; -import com.scriptopia.demo.domain.mongo.PlayerInfoMongo; -import com.scriptopia.demo.domain.mongo.RewardInfoMongo; +import com.scriptopia.demo.domain.mongo.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.EffectGradeDefRepository; @@ -15,6 +12,7 @@ import org.springframework.stereotype.Component; import java.security.SecureRandom; +import java.time.LocalDateTime; import java.util.*; @Component @@ -388,5 +386,60 @@ public static RewardInfoMongo getReward(RewardType rewardType, boolean isPass) { return builder.build(); } + public static PlayerInfoMongo updateReward(PlayerInfoMongo playerInfo, RewardInfoMongo rewardInfo) { + if (rewardInfo != null) { + if (rewardInfo.getRewardStrength() != null) + playerInfo.setStrength(playerInfo.getStrength() + rewardInfo.getRewardStrength()); -} + if (rewardInfo.getRewardAgility() != null) + playerInfo.setAgility(playerInfo.getAgility() + rewardInfo.getRewardAgility()); + + if (rewardInfo.getRewardIntelligence() != null) + playerInfo.setIntelligence(playerInfo.getIntelligence() + rewardInfo.getRewardIntelligence()); + + if (rewardInfo.getRewardLuck() != null) + playerInfo.setLuck(playerInfo.getLuck() + rewardInfo.getRewardLuck()); + + if (rewardInfo.getRewardLife() != null) + playerInfo.setLife(playerInfo.getLife() + rewardInfo.getRewardLife()); + + if (rewardInfo.getRewardGold() != null) + playerInfo.setGold(playerInfo.getGold() + rewardInfo.getRewardGold()); + + if (rewardInfo.getRewardTrait() != null) + playerInfo.setTrait(rewardInfo.getRewardTrait()); + } + + return playerInfo; + } + + + public static List updateRewardItem(List inventory, RewardInfoMongo rewardInfo) { + if (rewardInfo == null || rewardInfo.getGainedItemDefId() == null) { + return inventory; + } + + for (String id : rewardInfo.getGainedItemDefId()) { + InventoryMongo newItem = InventoryMongo.builder() + .itemDefId(id) + .equipped(false) + .source("Scenario") + .build(); + inventory.add(newItem); + } + + return inventory; + } + + public static List updateInGameItem(List inGameItem, RewardInfoMongo rewardInfo) { + if (rewardInfo == null || rewardInfo.getGainedItemDefId() == null) { + return inGameItem; + } + + for (String id : rewardInfo.getGainedItemDefId()) { + inGameItem.add(id); + } + + return inGameItem; + } +} \ No newline at end of file From 4cd6fcdafd54444ffdfa36dfd455c074c17438d4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 12 Sep 2025 23:09:50 +0900 Subject: [PATCH 401/527] refactor: change trades/confirm PATCH to POST --- .../java/com/scriptopia/demo/controller/AuctionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 47292400..0e5501fd 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -82,7 +82,7 @@ public ResponseEntity settlementHistory( } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PatchMapping("/{settlementId}/confirm") + @PostMapping("/{settlementId}/confirm") public ResponseEntity confirmItem( @PathVariable String settlementId, Authentication authentication) { From 677b68688c24ccd7c488c1bf6a9e0d27a516a6ca Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 12 Sep 2025 23:18:47 +0900 Subject: [PATCH 402/527] MyPageController edit --- .../controller/GameSessionController.java | 10 ++++- .../demo/controller/MyPageController.java | 38 ++++++++----------- .../demo/service/GameSessionService.java | 12 ------ .../demo/service/HistoryService.java | 8 ++++ 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index b66f23e8..eb05edb2 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/users/games") +@RequestMapping("/games") @RequiredArgsConstructor public class GameSessionController { private final GameSessionService gameSessionService; @@ -63,6 +63,14 @@ public ResponseEntity testGame( return ResponseEntity.ok(response); } + @GetMapping("/me") + public ResponseEntity getCurrentGame(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + + + } + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index b86abaef..33e23e23 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -19,8 +19,19 @@ public class MyPageController { private final SharedGameService sharedGameService; private final GameSessionService gameSessionService; + + /* + 게임 공유 -> 게임 공유하기 + */ + @PostMapping("/my-page/share/{uuid}") + public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.saveSharedGame(userId, uuid); + } + /* - 계정관리 : 내 히스토리 조회 -> 무한스크롤 + 유저 -> 사용자 게임 기록 조회 */ @GetMapping("/my-page/history") public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, @@ -32,7 +43,7 @@ public ResponseEntity> getHistory(@RequestParam(requir } /* - 계정관리 : 내가 공유한 게임 조회 + 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) */ @GetMapping("/my-page/games/shared") public ResponseEntity getMySharedGames(Authentication authentication) { @@ -41,18 +52,9 @@ public ResponseEntity getMySharedGames(Authentication authentication) { return sharedGameService.getMySharedGames(userId); } - /* - 계정관리 : 내 히스토리 공유하기 - */ - @PostMapping("/my-page/share/{uuid}") - public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.saveSharedGame(userId, uuid); - } /* - 계정관리 : 내가 공유한 게임 삭제 + 게임 공유 -> 공유한 게임 삭제 */ @DeleteMapping("/my-page/share/{uuid}") public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { @@ -64,7 +66,7 @@ public ResponseEntity delete(Authentication authentication, @PathVariable UUI } /* - 계정관리 : 게임 세션(이어하기) 조회 + 게임 -> 기존 게임 조회 */ @GetMapping("/my-page/game") public ResponseEntity loadGameSession(Authentication authentication) { @@ -72,14 +74,4 @@ public ResponseEntity loadGameSession(Authentication authentication) { return gameSessionService.getGameSession(userId); } - - /* - 계정관리 : 게임 세션(이어하기) 삭제 - */ - @DeleteMapping("/my-page/game/{gameId}") - public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String gameId) { - Long userId = Long.valueOf(authentication.getName()); - - return gameSessionService.deleteGameSession(userId, gameId); - } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d98d5b0b..b9ede53f 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -39,18 +39,6 @@ public class GameSessionService { private final MongoClient mongo; private final WebInvocationPrivilegeEvaluator privilegeEvaluator; - public boolean duplcatedGameSession(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - boolean game = gameSessionRepository.existsByUserId(user.getId()); - - if(game) { - return true; - } - else return false; - } - public ResponseEntity getGameSession(Long userid) { User user = userRepository.findById(userid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index b010a0a9..73652804 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -90,6 +90,13 @@ public ResponseEntity seedDummySession(Long userId) { return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); } + public ResponseEntity getHistory(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + + } + private HistoryRequest mapMongoToHistoryRequest(Document doc) { JsonNode root = asJson(doc); JsonNode hi = root.path("history_info"); @@ -146,4 +153,5 @@ public ResponseEntity> fetchMyHistory(Long userId, UUI return ResponseEntity.ok(page.getContent().stream().map(HistoryPageResponse::from).toList()); } + } From cc235ec9f1538835e68aba437f9e3f7bc11743ff Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 00:04:26 +0900 Subject: [PATCH 403/527] MyPageController edit -> SharedGameController --- .../controller/GameSessionController.java | 8 --- .../demo/controller/MyPageController.java | 38 +++------- .../demo/controller/SharedGameController.java | 71 +++++++++++++++---- .../demo/service/HistoryService.java | 7 -- 4 files changed, 66 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index eb05edb2..36d289e5 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -63,14 +63,6 @@ public ResponseEntity testGame( return ResponseEntity.ok(response); } - @GetMapping("/me") - public ResponseEntity getCurrentGame(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - - - } - /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index 33e23e23..aeac455f 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -20,16 +20,6 @@ public class MyPageController { private final GameSessionService gameSessionService; - /* - 게임 공유 -> 게임 공유하기 - */ - @PostMapping("/my-page/share/{uuid}") - public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.saveSharedGame(userId, uuid); - } - /* 유저 -> 사용자 게임 기록 조회 */ @@ -42,36 +32,24 @@ public ResponseEntity> getHistory(@RequestParam(requir return historyService.fetchMyHistory(userId, lastId, size); } - /* - 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) - */ - @GetMapping("/my-page/games/shared") - public ResponseEntity getMySharedGames(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.getMySharedGames(userId); - } - /* - 게임 공유 -> 공유한 게임 삭제 + 게임 -> 기존 게임 조회 */ - @DeleteMapping("/my-page/share/{uuid}") - public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { + @GetMapping("/my-page/game") + public ResponseEntity loadGameSession(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - sharedGameService.deleteSharedGame(userId, uuid); - - return ResponseEntity.ok("게임이 삭제되었습니다."); + return gameSessionService.getGameSession(userId); } /* - 게임 -> 기존 게임 조회 + 게임 -> 기존 게임 삭제 */ - @GetMapping("/my-page/game") - public ResponseEntity loadGameSession(Authentication authentication) { + @DeleteMapping("/my-page/game/{gameId}") + public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String gameId) { Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.getGameSession(userId); + return gameSessionService.deleteGameSession(userId, gameId); } } diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index c9936698..bb847f9a 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -1,7 +1,9 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.domain.SharedGameFavorite; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; +import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -12,29 +14,24 @@ import java.util.UUID; @RestController -@RequestMapping("/games/shared") +@RequestMapping("shared-games") @RequiredArgsConstructor public class SharedGameController { private final SharedGameService sharedGameService; + private final SharedGameFavoriteService sharedGameFavoriteService; /* - 게임공유 : 공유된 게임 상세 조회 + 게임 공유 -> 게임 공유하기 */ - @GetMapping("/{uuid}") - public ResponseEntity getSharedGameDetail(@PathVariable UUID uuid) { - return sharedGameService.getDetailedSharedGame(uuid); - } + @PostMapping + public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { + Long userId = Long.valueOf(authentication.getName()); - /* - 게임공유 : 공유된 게임 태그 조회 - */ - @GetMapping("/tags") - public ResponseEntity getSharedGameTags() { - return sharedGameService.getTag(); + return sharedGameService.saveSharedGame(userId, uuid); } /* - 게임공유 : 공유된 게임 목록 조회 + 게임 공유 -> 공유 게임 목록 조회 */ @GetMapping public ResponseEntity> getPublicSharedGames(Authentication authentication, @@ -45,4 +42,52 @@ public ResponseEntity> getPublicSharedGames Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, query); } + + /* + 게임공유 : 공유된 게임 상세 조회 + */ + @GetMapping("{sharedGameId}") + public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID sharedGameId) { + return sharedGameService.getDetailedSharedGame(sharedGameId); + } + + /* + 게임공유 : 공유 게임 Like 요청 + */ + @PostMapping("{sharedGameId}/like") + public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); + } + + /* + 게임공유 : 공유된 게임 태그 조회 + */ + @GetMapping("/tags") + public ResponseEntity getSharedGameTags() { + return sharedGameService.getTag(); + } + + /* + 게임 공유 -> 공유한 게임 삭제 + */ + @DeleteMapping("/shared-games") + public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { + Long userId = Long.valueOf(authentication.getName()); + + sharedGameService.deleteSharedGame(userId, uuid); + + return ResponseEntity.ok("게임이 삭제되었습니다."); + } + + /* + 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) + */ + @GetMapping("/me") + public ResponseEntity getMySharedGames(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return sharedGameService.getMySharedGames(userId); + } } diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 73652804..1ebaf343 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -90,13 +90,6 @@ public ResponseEntity seedDummySession(Long userId) { return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); } - public ResponseEntity getHistory(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - - } - private HistoryRequest mapMongoToHistoryRequest(Document doc) { JsonNode root = asJson(doc); JsonNode hi = root.path("history_info"); From b9a53405377de20dbe192a9865d9ed2199bc3d6d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 00:14:52 +0900 Subject: [PATCH 404/527] MyPageController edit --- .../demo/controller/GameSessionController.java | 13 +++++++++---- .../demo/controller/SharedGameController.java | 6 ++++++ .../demo/controller/TagDefController.java | 5 ++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 36d289e5..a24e7347 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -20,7 +20,10 @@ public class GameSessionController { private final GameSessionService gameSessionService; private final HistoryService historyService; - @PreAuthorize("hasAnyAuthority('USER')") + /* + 게임 -> 게임 도중 종료 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/{gameId}/exit") public ResponseEntity createGameSession(Authentication authentication, @PathVariable String gameId) { // 게임 세션 정보 저장 @@ -29,7 +32,10 @@ public ResponseEntity createGameSession(Authentication authentication, @PathV return gameSessionService.saveGameSession(userId, gameId); } - @PreAuthorize("hasAnyAuthority('USER')") + /* + 게임 -> 기존 게임 삭제 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping("/{gameId}") public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { Long userId = Long.valueOf(authentication.getName()); @@ -67,8 +73,7 @@ public ResponseEntity testGame( * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ - @PreAuthorize("hasAnyAuthority('USER')") - @PostMapping("/{gameId}/history") + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") public ResponseEntity addHistory(@PathVariable String gameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index bb847f9a..b7956e8d 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -7,6 +7,7 @@ import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -23,6 +24,7 @@ public class SharedGameController { /* 게임 공유 -> 게임 공유하기 */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { Long userId = Long.valueOf(authentication.getName()); @@ -54,6 +56,7 @@ public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID /* 게임공유 : 공유 게임 Like 요청 */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("{sharedGameId}/like") public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); @@ -64,6 +67,7 @@ public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authent /* 게임공유 : 공유된 게임 태그 조회 */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @GetMapping("/tags") public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); @@ -72,6 +76,7 @@ public ResponseEntity getSharedGameTags() { /* 게임 공유 -> 공유한 게임 삭제 */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping("/shared-games") public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { Long userId = Long.valueOf(authentication.getName()); @@ -84,6 +89,7 @@ public ResponseEntity delete(Authentication authentication, @PathVariable UUI /* 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @GetMapping("/me") public ResponseEntity getMySharedGames(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index c97745cc..843b9eb3 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -5,14 +5,16 @@ import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -@RestController("/admin/tag") +@RestController("/shared-games/tags") @RequiredArgsConstructor public class TagDefController { private final TagDefService tagDefService; + @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); @@ -20,6 +22,7 @@ public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, Authentica return tagDefService.addTagName(req, userId); } + @PreAuthorize("hasAnyAuthority('ADMIN')") @DeleteMapping public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); From 7a428a0fd48ad376796471b67ff60943484c194a Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 00:21:32 +0900 Subject: [PATCH 405/527] GameSessionController edit --- .../controller/GameSessionController.java | 24 +++++++++++++++---- .../demo/controller/MyPageController.java | 20 ---------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index a24e7347..d15b123a 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -25,7 +25,7 @@ public class GameSessionController { */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/{gameId}/exit") - public ResponseEntity createGameSession(Authentication authentication, @PathVariable String gameId) { + public ResponseEntity createGameSession(Authentication authentication, @PathVariable("gameId") String gameId) { // 게임 세션 정보 저장 Long userId = Long.valueOf(authentication.getName()); @@ -37,12 +37,12 @@ public ResponseEntity createGameSession(Authentication authentication, @PathV */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping("/{gameId}") - public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { + public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable("gameId") String gameId) { Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.deleteGameSession(userId, sessionId); + return gameSessionService.deleteGameSession(userId, gameId); } - + // 게임 시작 @PostMapping @@ -69,12 +69,26 @@ public ResponseEntity testGame( return ResponseEntity.ok(response); } + /* + 게임 -> 기존 게임 조회 + */ + @GetMapping("/me") + public ResponseEntity loadGameSession(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return gameSessionService.getGameSession(userId); + } + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 */ + /* + 게임 -> 히스토리 생성 + */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - public ResponseEntity addHistory(@PathVariable String gameId, Authentication authentication) { + @PostMapping("/{gameId}/history") + public ResponseEntity addHistory(@PathVariable("gameId") String gameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return historyService.createHistory(userId, gameId); diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java index aeac455f..5fd05149 100644 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ b/src/main/java/com/scriptopia/demo/controller/MyPageController.java @@ -32,24 +32,4 @@ public ResponseEntity> getHistory(@RequestParam(requir return historyService.fetchMyHistory(userId, lastId, size); } - - /* - 게임 -> 기존 게임 조회 - */ - @GetMapping("/my-page/game") - public ResponseEntity loadGameSession(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return gameSessionService.getGameSession(userId); - } - - /* - 게임 -> 기존 게임 삭제 - */ - @DeleteMapping("/my-page/game/{gameId}") - public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String gameId) { - Long userId = Long.valueOf(authentication.getName()); - - return gameSessionService.deleteGameSession(userId, gameId); - } } From 73e3dc4e8301e79776ff7aec117baf8bb9e3ad80 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 01:26:01 +0900 Subject: [PATCH 406/527] feat: create endPoint Get /games --- .../demo/controller/GameSessionController.java | 17 ++++++++++++++++- .../ingame/InGameBattleResponse.java | 4 ++++ .../ingame/InGameChoiceResponse.java | 4 ++++ .../gamesession/ingame/InGameDoneResponse.java | 4 ++++ .../ingame/InGameInventoryResponse.java | 4 ++++ .../gamesession/ingame/InGameNpcResponse.java | 4 ++++ .../ingame/InGamePlayerResponse.java | 4 ++++ .../gamesession/ingame/InGameShopResponse.java | 4 ++++ .../scriptopia/demo/mapper/InGameMapper.java | 4 ++++ 9 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java create mode 100644 src/main/java/com/scriptopia/demo/mapper/InGameMapper.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index d245f2e7..86421138 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -31,7 +31,7 @@ public ResponseEntity createGameSession(Authentication authentication, @PathV @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @DeleteMapping("/{gameId}") - public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable String sessionId) { + public ResponseEntity deleteGameSession(Authentication authentication, @PathVariable("gameId") String sessionId) { Long userId = Long.valueOf(authentication.getName()); return gameSessionService.deleteGameSession(userId, sessionId); @@ -75,6 +75,21 @@ public ResponseEntity choiceSelectGame( return ResponseEntity.ok(response); } + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @GetMapping() + public ResponseEntity getInGameData( + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + // service에서 sceneType별 DTO를 반환 + Object response = gameSessionService.getInGameDataDto(userId); + + return ResponseEntity.ok(response); + } + + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java new file mode 100644 index 00000000..647c03e4 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameBattleResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java new file mode 100644 index 00000000..5b52caac --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameChoiceResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java new file mode 100644 index 00000000..711b56a8 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameDoneResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java new file mode 100644 index 00000000..0cfa10c4 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameInventoryResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java new file mode 100644 index 00000000..101bcd0c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameNpcResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java new file mode 100644 index 00000000..c532123d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGamePlayerResponse { +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java new file mode 100644 index 00000000..9f3021d0 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameShopResponse { +} diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java new file mode 100644 index 00000000..eaa38e4c --- /dev/null +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.mapper; + +public class InGameMapper { +} From 7772ce5918e5b8d44e0a845c938820ad9fab2191 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 01:27:44 +0900 Subject: [PATCH 407/527] feat: implement update user settings --- .../demo/controller/AuthController.java | 5 +- .../demo/controller/OAuthController.java | 1 - .../demo/controller/UserController.java | 22 ++++++-- .../demo/controller/refreshController.java | 2 +- .../ChangePasswordRequest.java | 14 ++--- .../{localaccount => auth}/LoginRequest.java | 8 +-- .../{localaccount => auth}/LoginResponse.java | 2 +- .../RefreshResponse.java | 2 +- .../RegisterRequest.java | 12 ++-- .../ResetPasswordRequest.java | 8 +-- .../SendCodeRequest.java | 6 +- .../SendResetMailRequest.java | 6 +- .../VerifyCodeRequest.java | 6 +- .../VerifyEmailRequest.java | 6 +- .../demo/dto/oauth/OAuthUserInfo.java | 1 + .../demo/dto/oauth/SocialSignupRequest.java | 2 + .../demo/dto/users/GetSettingsResponse.java | 20 ------- .../demo/dto/users/UserSettingsDTO.java | 39 +++++++++++++ .../scriptopia/demo/exception/ErrorCode.java | 2 + .../exception/GlobalExceptionHandler.java | 55 +++++++------------ .../repository/UserSettingRepository.java | 5 +- .../demo/service/LocalAccountService.java | 3 +- .../scriptopia/demo/service/UserService.java | 34 ++++++++++-- src/main/resources/application.yml | 2 +- 24 files changed, 151 insertions(+), 112 deletions(-) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/ChangePasswordRequest.java (53%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/LoginRequest.java (63%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/LoginResponse.java (86%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/RefreshResponse.java (83%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/RegisterRequest.java (55%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/ResetPasswordRequest.java (61%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/SendCodeRequest.java (61%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/SendResetMailRequest.java (61%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/VerifyCodeRequest.java (63%) rename src/main/java/com/scriptopia/demo/dto/{localaccount => auth}/VerifyEmailRequest.java (61%) delete mode 100644 src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/users/UserSettingsDTO.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 4cd7f94d..9400b495 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,6 +1,6 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.localaccount.*; +import com.scriptopia.demo.dto.auth.*; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.service.RefreshTokenService; import jakarta.servlet.http.HttpServletRequest; @@ -13,9 +13,6 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.time.Duration; -import java.util.List; - @RestController @RequestMapping("/auth") @RequiredArgsConstructor diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index e38521e4..f065dbcd 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -1,7 +1,6 @@ package com.scriptopia.demo.controller; import com.fasterxml.jackson.core.JsonProcessingException; -import com.scriptopia.demo.dto.localaccount.LoginResponse; import com.scriptopia.demo.dto.oauth.OAuthLoginResponse; import com.scriptopia.demo.dto.oauth.SocialSignupRequest; import com.scriptopia.demo.service.OAuthService; diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index bd24522f..12028453 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,14 +1,13 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.dto.users.GetSettingsResponse; +import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.service.UserService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/users/me") @@ -19,11 +18,22 @@ public class UserController { @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/settings") - public ResponseEntity getUserSettings( + public ResponseEntity getUserSettings( Authentication authentication ) { String userId = authentication.getName(); - GetSettingsResponse response = userService.getUserSettings(userId); + UserSettingsDTO response = userService.getUserSettings(userId); return ResponseEntity.ok(response); } + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @PutMapping("/settings") + public ResponseEntity updateUserSettings( + Authentication authentication, + @RequestBody @Valid UserSettingsDTO request + ) { + String userId = authentication.getName(); + userService.updateUserSettings(userId,request); + return ResponseEntity.ok("사용자 설정이 변경되었습니다."); + } } diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java index e5c7f500..875dc9a3 100644 --- a/src/main/java/com/scriptopia/demo/controller/refreshController.java +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.config.JwtProperties; -import com.scriptopia.demo.dto.localaccount.RefreshResponse; +import com.scriptopia.demo.dto.auth.RefreshResponse; import com.scriptopia.demo.dto.token.RefreshRequest; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.service.RefreshTokenService; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/ChangePasswordRequest.java similarity index 53% rename from src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/ChangePasswordRequest.java index 0bf5d5d6..349a0d60 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/ChangePasswordRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -13,19 +13,19 @@ public class ChangePasswordRequest { - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @NotBlank(message = "E_400_MISSING_PASSWORD") + @Size(min = 8, max = 20, message = "E_400_PASSWORD_SIZE") @Pattern( regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", - message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + message = "E_400_PASSWORD_COMPLEXITY" ) private String oldPassword; - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @NotBlank(message = "E_400_MISSING_PASSWORD\"") + @Size(min = 8, max = 20, message = "E_400_PASSWORD_SIZE") @Pattern( regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", - message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + message = "E_400_PASSWORD_COMPLEXITY" ) private String newPassword; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java similarity index 63% rename from src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java index a4d85fe1..6e6c7df7 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,11 +11,11 @@ @NoArgsConstructor public class LoginRequest { - @NotBlank(message = "이메일을 입력해주세요.") - @Email(message = "이메일 형식이 올바르지 않습니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; - @NotBlank(message = "비밀번호를 입력해주세요.") + @NotBlank(message = "E_400_MISSING_PASSWORD") private String password; @NotBlank(message = "디바이스 식별값이 필요합니다.") diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java b/src/main/java/com/scriptopia/demo/dto/auth/LoginResponse.java similarity index 86% rename from src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java rename to src/main/java/com/scriptopia/demo/dto/auth/LoginResponse.java index 6ada8212..f09375c1 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/LoginResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/LoginResponse.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import com.scriptopia.demo.domain.Role; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java b/src/main/java/com/scriptopia/demo/dto/auth/RefreshResponse.java similarity index 83% rename from src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java rename to src/main/java/com/scriptopia/demo/dto/auth/RefreshResponse.java index b8f6816d..7f8b8cad 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/RefreshResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/RefreshResponse.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java similarity index 55% rename from src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java index aaebc6db..5e896916 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -13,17 +13,17 @@ @NoArgsConstructor public class RegisterRequest { - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; - @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @Size(min = 8, max = 20, message = "E_400_PASSWORD_SIZE") @Pattern( regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", - message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + message = "E_400_PASSWORD_COMPLEXITY" ) private String password; - @NotBlank(message = "닉네임은 필수 입력 값입니다.") + @NotBlank(message = "E_400_MISSING_NICKNAME") private String nickname; } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/ResetPasswordRequest.java similarity index 61% rename from src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/ResetPasswordRequest.java index ccb160d1..8c01cd8a 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/ResetPasswordRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/ResetPasswordRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -14,11 +14,11 @@ public class ResetPasswordRequest { private String token; - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8~20자리여야 합니다.") + @NotBlank(message = "E_400_MISSING_PASSWORD") + @Size(min = 8, max = 20, message = "E_400_PASSWORD_SIZE") @Pattern( regexp = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", - message = "비밀번호는 소문자, 숫자, 특수문자를 포함해야 합니다." + message = "E_400_PASSWORD_COMPLEXITY" ) private String newPassword; } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/SendCodeRequest.java similarity index 61% rename from src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/SendCodeRequest.java index 881698e4..7792999b 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/SendCodeRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/SendCodeRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,7 +11,7 @@ @NoArgsConstructor public class SendCodeRequest { - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/SendResetMailRequest.java similarity index 61% rename from src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/SendResetMailRequest.java index fa40c1e3..e8fd085a 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/SendResetMailRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/SendResetMailRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,7 +11,7 @@ @NoArgsConstructor public class SendResetMailRequest { - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; } diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/VerifyCodeRequest.java similarity index 63% rename from src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/VerifyCodeRequest.java index 34273b77..599d7c49 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyCodeRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/VerifyCodeRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,8 +11,8 @@ @NoArgsConstructor public class VerifyCodeRequest { - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; private String code; diff --git a/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/VerifyEmailRequest.java similarity index 61% rename from src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java rename to src/main/java/com/scriptopia/demo/dto/auth/VerifyEmailRequest.java index 7efbd084..cc6d5abc 100644 --- a/src/main/java/com/scriptopia/demo/dto/localaccount/VerifyEmailRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/VerifyEmailRequest.java @@ -1,4 +1,4 @@ -package com.scriptopia.demo.dto.localaccount; +package com.scriptopia.demo.dto.auth; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,7 +11,7 @@ @NoArgsConstructor public class VerifyEmailRequest { - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @NotBlank(message = "E_400_MISSING_EMAIL") + @Email(message = "E_400_INVALID_EMAIL_FORMAT") private String email; } diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java index fd5b839a..e5f3b0a7 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/OAuthUserInfo.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.oauth; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.*; diff --git a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java index 49158591..2f099a22 100644 --- a/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/oauth/SocialSignupRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.oauth; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,6 +11,7 @@ @AllArgsConstructor @NoArgsConstructor public class SocialSignupRequest { + @NotBlank(message = "E_400_MISSING_NICKNAME") private String nickname; private String deviceId; private String signupToken; diff --git a/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java b/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java deleted file mode 100644 index 08d9aab7..00000000 --- a/src/main/java/com/scriptopia/demo/dto/users/GetSettingsResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.scriptopia.demo.dto.users; - -import com.scriptopia.demo.domain.FontType; -import com.scriptopia.demo.domain.Theme; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class GetSettingsResponse { - private Theme theme; - private Integer fondSize; - private FontType font; - private Integer lineHeight; - private Integer wordSpacing; -} diff --git a/src/main/java/com/scriptopia/demo/dto/users/UserSettingsDTO.java b/src/main/java/com/scriptopia/demo/dto/users/UserSettingsDTO.java new file mode 100644 index 00000000..0ed93cf3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/UserSettingsDTO.java @@ -0,0 +1,39 @@ +package com.scriptopia.demo.dto.users; + +import com.scriptopia.demo.domain.FontType; +import com.scriptopia.demo.domain.Theme; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class UserSettingsDTO { + + @NotNull(message = "E_400") + private Theme theme; + + @NotNull(message = "E_400") + @Min(value = 10, message = "E_400") + @Max(value = 24, message = "E_400") + private Integer fontSize; + + @NotNull(message = "E_400") + private FontType font; + + @NotNull(message = "E_400") + @Min(value = 1, message = "E_400") + @Max(value = 10, message = "E_400") + private Integer lineHeight; + + @NotNull(message = "E_400") + @Min(value = 1, message = "E_400") + @Max(value = 10, message = "E_400") + private Integer wordSpacing; +} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index ae76c432..42fc59cf 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -38,6 +38,7 @@ public enum ErrorCode { E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), + E_400_INVALID_ENUM_TYPE("E4000028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), //401 Unauthorized @@ -70,6 +71,7 @@ public enum ErrorCode { E_404_Duplicated_Game_Session("E404008", "이미 저장된 게임이 존재합니다.", HttpStatus.NOT_FOUND), E_404_ITEM_NOT_FOUND("E404009", "아이템이 없습니다.", HttpStatus.NOT_FOUND), E_404_PAGE_NOT_FOUND("E404010", "페이지가 없습니다.", HttpStatus.NOT_FOUND), + E_404_SETTING_NOT_FOUND("E404011", "유저 설정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index 66852d77..ed422c1d 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -4,56 +4,39 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import com.scriptopia.demo.dto.exception.ErrorResponse; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException e) { FieldError fieldError = e.getBindingResult().getFieldError(); ErrorCode errorCode = ErrorCode.E_400; - if (fieldError == null || fieldError.getDefaultMessage() == null) { - // 기본값 - } - else { - if ("email".equals(fieldError.getField())){ - if (Objects.equals(fieldError.getCode(), "NotBlank")) { - errorCode = ErrorCode.E_400_MISSING_EMAIL; - } else if (Objects.equals(fieldError.getCode(), "Email")) { - errorCode = ErrorCode.E_400_INVALID_EMAIL_FORMAT; - } - } - else if ("password".equals(fieldError.getField()) || - "oldPassword".equals(fieldError.getField()) || - "newPassword".equals(fieldError.getField())){ - if (Objects.equals(fieldError.getCode(), "NotBlank")) { - errorCode = ErrorCode.E_400_MISSING_PASSWORD; - } else if (Objects.equals(fieldError.getCode(), "Size")) { - errorCode = ErrorCode.E_400_PASSWORD_SIZE; - } - else if (Objects.equals(fieldError.getCode(), "Pattern")) { - errorCode = ErrorCode.E_400_PASSWORD_COMPLEXITY; - } - } - else if ("nickname".equals(fieldError.getField())){ - if (Objects.equals(fieldError.getCode(), "NotBlank")) { - errorCode = ErrorCode.E_400_MISSING_NICKNAME; - } - } + if (fieldError != null && fieldError.getDefaultMessage() != null) { + errorCode = ErrorCode.valueOf(fieldError.getDefaultMessage()); } + return ResponseEntity.badRequest().body(new ErrorResponse(errorCode)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleEnumBinding(HttpMessageNotReadableException ex) { return ResponseEntity - .status(errorCode.getStatus()) - .body(new ErrorResponse(errorCode)); + .status(ErrorCode.E_400.getStatus()) + .body(new ErrorResponse(ErrorCode.E_400)); } @ExceptionHandler(AccessDeniedException.class) @@ -80,11 +63,11 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { } -// @ExceptionHandler(Exception.class) -// public ResponseEntity handleGeneralException(Exception ex) { + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { -// return ResponseEntity -// .status(HttpStatus.INTERNAL_SERVER_ERROR) -// .body(new ErrorResponse(ErrorCode.E_500)); -// } + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(ErrorCode.E_500)); + } } diff --git a/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java index 9386e024..54a32036 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserSettingRepository.java @@ -1,9 +1,12 @@ package com.scriptopia.demo.repository; +import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserSetting; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserSettingRepository extends JpaRepository { - UserSetting findByUserId(Long userId); + Optional findByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index 926d344a..fcf95d97 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -2,7 +2,7 @@ import com.scriptopia.demo.config.JwtProperties; import com.scriptopia.demo.domain.*; -import com.scriptopia.demo.dto.localaccount.*; +import com.scriptopia.demo.dto.auth.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.LocalAccountRepository; @@ -22,7 +22,6 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.List; -import java.util.Locale; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index 5865fd59..08d1aacc 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -1,12 +1,16 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.UserSetting; -import com.scriptopia.demo.dto.users.GetSettingsResponse; +import com.scriptopia.demo.dto.users.UserSettingsDTO; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -15,13 +19,15 @@ public class UserService { private final UserSettingRepository userSettingRepository; @Transactional - public GetSettingsResponse getUserSettings(String userId){ + public UserSettingsDTO getUserSettings(String userId){ - UserSetting userSetting = userSettingRepository.findByUserId(Long.valueOf(userId)); + UserSetting userSetting = userSettingRepository.findByUserId(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_SETTING_NOT_FOUND) + ); - return GetSettingsResponse.builder() + return UserSettingsDTO.builder() .theme(userSetting.getTheme()) - .fondSize(userSetting.getFontSize()) + .fontSize(userSetting.getFontSize()) .font(userSetting.getFontType()) .lineHeight(userSetting.getLineHeight()) .wordSpacing(userSetting.getWordSpacing()) @@ -29,4 +35,22 @@ public GetSettingsResponse getUserSettings(String userId){ } + @Transactional + public void updateUserSettings(String userId, UserSettingsDTO request){ + UserSetting userSetting = userSettingRepository.findByUserId(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_SETTING_NOT_FOUND) + ); + + userSetting.setTheme(request.getTheme()); + userSetting.setFontSize(request.getFontSize()); + userSetting.setFontType(request.getFont()); + userSetting.setLineHeight(request.getLineHeight()); + userSetting.setWordSpacing(request.getWordSpacing()); + userSetting.setUpdatedAt(LocalDateTime.now()); + + + } + + + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70b92ee8..8d8737c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: show_sql: true From 13c5f268ef03789a9c1722f043bfee538ecb1fc7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 01:27:51 +0900 Subject: [PATCH 408/527] feat: create mapper directory to make DTO --- .../ingame/InGameChoiceResponse.java | 36 ++++++ .../ingame/InGameInventoryResponse.java | 43 ++++++++ .../gamesession/ingame/InGameNpcResponse.java | 19 ++++ .../ingame/InGamePlayerResponse.java | 23 ++++ .../scriptopia/demo/mapper/InGameMapper.java | 103 ++++++++++++++++++ 5 files changed, 224 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java index 5b52caac..2a738db0 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameChoiceResponse.java @@ -1,4 +1,40 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class InGameChoiceResponse { + + private String sceneType; + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + private String background; + private String location; + private int progress; + private int stageSize; + + private InGamePlayerResponse playerInfo; // 외부 + private InGameNpcResponse npcInfo; // 외부 + private List inventory; // 외부 + private List choiceInfo; // 내부 + + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Choice { + private String detail; + private String stats; // "STRENGTH", "AGILITY", "INTELLIGENCE", "LUCK" + private int probability; + } } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java index 0cfa10c4..861bed77 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameInventoryResponse.java @@ -1,4 +1,47 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder public class InGameInventoryResponse { + // 소유자 정보 + private String itemDefId; + private LocalDateTime acquiredAt; + private boolean equipped; + private String source; + + // 아이템 정의 정보 + private String name; + private String description; + private String itemPicSrc; + private String category; + private int baseStat; + private List itemEffects; + private int strength; + private int agility; + private int intelligence; + private int luck; + private String mainStat; + private String grade; + private int price; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ItemEffect { + private String itemEffectName; + private String itemEffectDescription; + private String grade; + } } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java index 101bcd0c..9b9007c8 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java @@ -1,4 +1,23 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder public class InGameNpcResponse { + private String name; + private int rank; + private String trait; + private int strength; + private int agility; + private int intelligence; + private int luck; + private String npcWeaponName; + private String npcWeaponDescription; + } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java index c532123d..59535dc6 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGamePlayerResponse.java @@ -1,4 +1,27 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder public class InGamePlayerResponse { + private String name; + private int life; + private int level; + private int healthPoint; + private int experiencePoint; + private String trait; + private int strength; + private int agility; + private int intelligence; + private int luck; + private long gold; } + + diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java index eaa38e4c..0940e4f9 100644 --- a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -1,4 +1,107 @@ package com.scriptopia.demo.mapper; + +import com.scriptopia.demo.domain.mongo.*; +import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; +import com.scriptopia.demo.dto.gamesession.ingame.InGameInventoryResponse; +import com.scriptopia.demo.dto.gamesession.ingame.InGameNpcResponse; +import com.scriptopia.demo.dto.gamesession.ingame.InGamePlayerResponse; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor public class InGameMapper { + private final ItemDefMongoRepository itemDefMongoRepository; + + + public InGamePlayerResponse mapPlayer(PlayerInfoMongo player) { + if (player == null) return null; + return InGamePlayerResponse.builder() + .name(player.getName()) + .life(player.getLife()) + .level(player.getLevel()) + .healthPoint(player.getHealthPoint()) + .experiencePoint(player.getExperiencePoint()) + .trait(player.getTrait()) + .strength(player.getStrength()) + .agility(player.getAgility()) + .intelligence(player.getIntelligence()) + .luck(player.getLuck()) + .gold(player.getGold()) + .build(); + } + + public InGameNpcResponse mapNpc(NpcInfoMongo npc) { + if (npc == null) return null; + return InGameNpcResponse.builder() + .name(npc.getName()) + .rank(npc.getRank()) + .trait(npc.getTrait()) + .strength(npc.getStrength()) + .agility(npc.getAgility()) + .intelligence(npc.getIntelligence()) + .luck(npc.getLuck()) + .npcWeaponName(npc.getNpcWeaponName()) + .npcWeaponDescription(npc.getNpcWeaponDescription()) + .build(); + } + + public List mapInventory(List inventoryList) { + if (inventoryList == null) return List.of(); + + return inventoryList.stream() + .map(inv -> { + ItemDefMongo itemDef = itemDefMongoRepository.findById(inv.getItemDefId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + return InGameInventoryResponse.builder() + // 인벤토리(소유) 정보 + .itemDefId(inv.getItemDefId()) + .acquiredAt(inv.getAcquiredAt()) + .equipped(inv.isEquipped()) + .source(inv.getSource()) + + // 아이템 정의 정보 + .name(itemDef.getName()) + .description(itemDef.getDescription()) + .itemPicSrc(itemDef.getItemPicSrc()) + .category(itemDef.getCategory().name()) + .baseStat(itemDef.getBaseStat()) + .itemEffects(itemDef.getItemEffect().stream() + .map(e -> InGameInventoryResponse.ItemEffect.builder() + .itemEffectName(e.getItemEffectName()) + .itemEffectDescription(e.getItemEffectDescription()) + .grade(e.getEffectProbability().name()) + .build()) + .toList()) + .strength(itemDef.getStrength()) + .agility(itemDef.getAgility()) + .intelligence(itemDef.getIntelligence()) + .luck(itemDef.getLuck()) + .mainStat(itemDef.getMainStat().name()) + .grade(itemDef.getGrade().name()) + .price(itemDef.getPrice().intValue()) + .build(); + }) + .toList(); + } + + public List mapChoice(ChoiceInfoMongo choiceInfo) { + if (choiceInfo == null || choiceInfo.getChoice() == null) return List.of(); + + return choiceInfo.getChoice().stream() + .limit(3) // 최대 3개만 + .map(ch -> InGameChoiceResponse.Choice.builder() + .detail(ch.getDetail()) + .stats(ch.getStats() != null ? ch.getStats().name() : null) // null-safe + .probability(ch.getProbability()) + .build()) + .toList(); + } } From d95186f4e1bf82f215a80f54cf500057d3895e4b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 01:28:37 +0900 Subject: [PATCH 409/527] refactor: add mapper to response log --- .../demo/service/GameSessionService.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 6225e675..5f8b4e5b 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,8 +1,10 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.config.fastapi.FastApiClient; +import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; +import com.scriptopia.demo.mapper.InGameMapper; import com.scriptopia.demo.repository.mongo.GameSessionMongoRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; import com.scriptopia.demo.utils.GameBalanceUtil; @@ -39,6 +41,7 @@ public class GameSessionService { private final UserItemRepository userItemRepository; private final FastApiService fastApiService; private final ItemDefService itemDefService; + private final InGameMapper inGameMapper; public boolean duplcatedGameSession(Long userId) { @@ -271,6 +274,47 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { ); } + public Object getInGameDataDto(Long userId){ + if (!gameSessionRepository.existsByUserId(userId)) { + throw new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND); + } + + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + String gameId = gameSession.getMongoId(); + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + + SceneType currentSceneType = gameSessionMongo.getSceneType(); + if (currentSceneType == SceneType.CHOICE) { + return InGameChoiceResponse.builder() + .sceneType("CHOICE") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(gameSessionMongo.getUpdatedAt()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .progress(gameSessionMongo.getProgress()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory()) ) + .choiceInfo(inGameMapper.mapChoice(gameSessionMongo.getChoiceInfo())) + .build(); + + } else if (currentSceneType == SceneType.DONE) { + + } else if (currentSceneType == SceneType.SHOP) { + + } else if (currentSceneType == SceneType.BATTLE) { + + } + + return null; + } + /** * 게임 진행 From 149b9c654640ef4e8a58a6cb726d6336193c2a3c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 01:30:53 +0900 Subject: [PATCH 410/527] refactor: change create to update --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70b92ee8..8d8737c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: show_sql: true From 6564b82890fd451473aa4a0a608b85632cedd95d Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 01:42:19 +0900 Subject: [PATCH 411/527] refactor: add endPoint to getInGameData --- .../com/scriptopia/demo/controller/GameSessionController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 656768cf..c8197283 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -78,8 +78,9 @@ public ResponseEntity loadGameSession(Authentication authentication) { @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @GetMapping() + @GetMapping("/{gameId}") public ResponseEntity getInGameData( + @PathVariable("gameId") String gameId, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); From 3bc0ecdfc43844ab368ff7852473446a2c09fe7b Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 02:11:44 +0900 Subject: [PATCH 412/527] feat: add endpoint for user assets query in UserController --- .../scriptopia/demo/controller/UserController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 12028453..45ee1870 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.service.UserService; import jakarta.validation.Valid; @@ -36,4 +37,15 @@ public ResponseEntity updateUserSettings( userService.updateUserSettings(userId,request); return ResponseEntity.ok("사용자 설정이 변경되었습니다."); } + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @GetMapping("/assets") + public ResponseEntity getUserAssets( + Authentication authentication + ) { + String userId = authentication.getName(); + UserAssetsResponse response = userService.getUserAssets(userId); + return ResponseEntity.ok(response); + } + } From 36d097d1e1bd18ff1590ed5888a30acc352c8119 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 02:11:44 +0900 Subject: [PATCH 413/527] feat: implement service logic for user assets query in UserService --- .../scriptopia/demo/service/UserService.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index 08d1aacc..d7d38418 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -1,9 +1,12 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserSetting; +import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,6 +20,7 @@ public class UserService { private final UserSettingRepository userSettingRepository; + private final UserRepository userRepository; @Transactional public UserSettingsDTO getUserSettings(String userId){ @@ -51,6 +55,19 @@ public void updateUserSettings(String userId, UserSettingsDTO request){ } + @Transactional + public UserAssetsResponse getUserAssets(String userId){ + + User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + + return UserAssetsResponse.builder() + .pia(user.getPia()) + .build(); + + } + } From 4ce44c280aa7fd604a4bc14af062f7e8075bf55f Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 02:11:45 +0900 Subject: [PATCH 414/527] feat: add UserAssetsResponse DTO for asset query response --- .../demo/dto/users/UserAssetsResponse.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/users/UserAssetsResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/users/UserAssetsResponse.java b/src/main/java/com/scriptopia/demo/dto/users/UserAssetsResponse.java new file mode 100644 index 00000000..ec4b0dbe --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/UserAssetsResponse.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.users; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class UserAssetsResponse { + private Long pia; +} From 5c62f70a0403994de763175469ee785f4074cc13 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 02:14:27 +0900 Subject: [PATCH 415/527] Gamesession refactor --- .DS_Store | Bin 6148 -> 6148 bytes .../controller/GameSessionController.java | 34 +++++++++++------- .../demo/controller/SharedGameController.java | 4 +-- .../dto/gamesession/GameSessionRequest.java | 2 +- .../dto/gamesession/GameSessionResponse.java | 1 - .../demo/service/GameSessionService.java | 5 ++- 6 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.DS_Store b/.DS_Store index 42166a68392d38d4f9b5dee3c6b0bd607b3d7a33..07162ff4348b97369ca6d397eafad543a9d186f3 100644 GIT binary patch delta 278 zcmZoMXfc=|#>B)qu~2NHo+2aD!~pA!4;mOJ8;Gz>?ANSMDlaZb%E?b+U|`shRFIQd zTw-8wjgg6&g_Vt+os*rLJvKNazdX1kv81%vDX}OT#0$yK&q;!@6O+O+Q_JH8M4a>U zN)j{kQj5SEGE-84N@Bt@^HTE5o$^cbQi{QPgCP=}oE)6-0ut4()fSfKItnI+Cbc>W z)t1JlK(>iVZ7nBfYGH&AOSLgP#NF&y5$q cGf(Ch(G>y7f^;-MXt3tZAtD=?CpNGE0CEjTd;kCd delta 68 zcmZoMXfc=|#>CJzu~2NHo+2aT!~knX#?612xLG#`Fc&awX6NAN07`FmWd6=PnO{Vg WlaYae;Q$abOt#^X-W(&cgc$&6rx2$A diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index c8197283..4478b04e 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.fasterxml.jackson.core.JsonProcessingException; +import com.scriptopia.demo.domain.GameSession; import com.scriptopia.demo.domain.mongo.GameSessionMongo; import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.service.GameSessionService; @@ -23,22 +24,20 @@ public class GameSessionController { * 게임 -> 게임 도중 종료 */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/{gameId}/exit") - public ResponseEntity createGameSession(Authentication authentication, - @PathVariable("gameId") String gameId) { + @PostMapping("/exit") + public ResponseEntity createGameSession(Authentication authentication, @RequestBody GameSessionRequest request) { Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.saveGameSession(userId, gameId); + return gameSessionService.saveGameSession(userId, request.getGameId()); } /* * 게임 -> 기존 게임 삭제 */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @DeleteMapping("/{gameId}") - public ResponseEntity deleteGameSession(Authentication authentication, - @PathVariable("gameId") String gameId) { + @DeleteMapping("") + public ResponseEntity deleteGameSession(Authentication authentication, @RequestBody GameSessionRequest request) { Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.deleteGameSession(userId, gameId); + return gameSessionService.deleteGameSession(userId, request.getGameId()); } @@ -55,6 +54,7 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } +<<<<<<< Updated upstream @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/progress") @@ -67,6 +67,17 @@ public ResponseEntity keepGame( return ResponseEntity.ok(response); } + /** + * 테스트 중 + */ + @PostMapping("/test") + public ResponseEntity testGame(Authentication authentication) + throws JsonProcessingException { + Long userId = Long.valueOf(authentication.getName()); + GameSessionMongo response = gameSessionService.mapToCreateGameChoiceRequest(userId); + return ResponseEntity.ok(response); + } + /* * 게임 -> 기존 게임 조회 */ @@ -100,11 +111,10 @@ public ResponseEntity getInGameData( * 게임 -> 히스토리 생성 */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/{gameId}/history") - public ResponseEntity addHistory(@PathVariable("gameId") String gameId, - Authentication authentication) { + @PostMapping("/history") + public ResponseEntity addHistory(Authentication authentication, @RequestBody GameSessionRequest request) { Long userId = Long.valueOf(authentication.getName()); - return historyService.createHistory(userId, gameId); + return historyService.createHistory(userId, request.getGameId()); } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index b7956e8d..128f9d78 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -15,7 +15,7 @@ import java.util.UUID; @RestController -@RequestMapping("shared-games") +@RequestMapping("/shared-games") @RequiredArgsConstructor public class SharedGameController { private final SharedGameService sharedGameService; @@ -58,7 +58,7 @@ public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("{sharedGameId}/like") - public ResponseEntity likeSharedGame(@PathVariable Long sharedGameId, Authentication authentication) { + public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") Long sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java index f3e90472..89014086 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionRequest.java @@ -8,5 +8,5 @@ @NoArgsConstructor @AllArgsConstructor public class GameSessionRequest { - private String token; + private String gameId; } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java index 6245b7d5..887dfd32 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameSessionResponse.java @@ -8,6 +8,5 @@ @NoArgsConstructor @AllArgsConstructor public class GameSessionResponse { - private Long id; private String sessionId; } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 07b470b4..27424a32 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -52,7 +52,10 @@ public ResponseEntity getGameSession(Long userid) { GameSession sessions = gameSessionRepository.findByMongoId(user.getId()) .orElseThrow(() -> new CustomException(ErrorCode.E_404_STORED_GAME_NOT_FOUND)); - return ResponseEntity.ok(sessions); + GameSessionResponse gameSessionResponse = new GameSessionResponse(); + gameSessionResponse.setSessionId(sessions.getMongoId()); + + return ResponseEntity.ok(gameSessionResponse); } @Transactional From 3748062184a71d20be617a3b428fe2d2c4b2df62 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 02:19:27 +0900 Subject: [PATCH 416/527] feat: return response to BATTE SceneType --- .../controller/GameSessionController.java | 45 +++++++++++++------ .../ingame/InGameBattleResponse.java | 39 ++++++++++++++++ .../demo/service/GameSessionService.java | 31 +++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index c8197283..d57b69cb 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -57,41 +57,58 @@ public ResponseEntity startNewGame( @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/progress") - public ResponseEntity keepGame( + @GetMapping("/{gameId}") + public ResponseEntity getInGameData( + @PathVariable("gameId") String gameId, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - GameSessionMongo response = gameSessionService.gameProgress(userId); + // service에서 sceneType별 DTO를 반환 + Object response = gameSessionService.getInGameDataDto(userId); + return ResponseEntity.ok(response); } - /* - * 게임 -> 기존 게임 조회 - */ - @GetMapping("/me") - public ResponseEntity loadGameSession(Authentication authentication) { + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @PostMapping("/progress") + public ResponseEntity keepGame( + Authentication authentication) throws JsonProcessingException { + Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.getGameSession(userId); + + GameSessionMongo response = gameSessionService.gameProgress(userId); + return ResponseEntity.ok(response); } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @GetMapping("/{gameId}") - public ResponseEntity getInGameData( - @PathVariable("gameId") String gameId, + @PostMapping("/test") + public ResponseEntity selectChoice( + @RequestBody GameChoiceRequest request, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - // service에서 sceneType별 DTO를 반환 - Object response = gameSessionService.getInGameDataDto(userId); + GameSessionMongo response = gameSessionService.gameChoiceSelect(userId, request); return ResponseEntity.ok(response); } + + /* + * 게임 -> 기존 게임 조회 + */ + @GetMapping("/me") + public ResponseEntity loadGameSession(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return gameSessionService.getGameSession(userId); + } + + + /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java index 647c03e4..6ab96352 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameBattleResponse.java @@ -1,4 +1,43 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class InGameBattleResponse { + private String sceneType; + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + private String background; + private String location; + private int progress; + private int stageSize; + + private InGamePlayerResponse playerInfo; // 외부 + private InGameNpcResponse npcInfo; // 외부 + private List inventory; // 외부 + + + private Long curTurnId; // 현재 턴 + private List playerHp; // 플레이어 체력 로그 + private List enemyHp; // NPC 체력 로그 + private List battleStory; // 턴별 전투 스토리 + private Boolean playerWin; // 승리 여부 + + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BattleStoryResponse { + private String turnInfo; // 해당 턴 설명 + } } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 07b470b4..af4f96a3 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.config.fastapi.FastApiClient; +import com.scriptopia.demo.dto.gamesession.ingame.InGameBattleResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; @@ -294,9 +295,39 @@ public Object getInGameDataDto(Long userId){ } else if (currentSceneType == SceneType.DONE) { + } else if (currentSceneType == SceneType.SHOP) { } else if (currentSceneType == SceneType.BATTLE) { + BattleInfoMongo battleInfo = gameSessionMongo.getBattleInfo(); + + return InGameBattleResponse.builder() + .sceneType("BATTLE") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(gameSessionMongo.getUpdatedAt()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .progress(gameSessionMongo.getProgress()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) + .playerHp(battleInfo != null && battleInfo.getPlayerHp() != null + ? battleInfo.getPlayerHp().stream().map(Long::intValue).toList() + : List.of()) + .enemyHp(battleInfo != null && battleInfo.getEnemyHp() != null + ? battleInfo.getEnemyHp().stream().map(Long::intValue).toList() + : List.of()) + .battleStory(battleInfo != null && battleInfo.getBattleTurn() != null + ? battleInfo.getBattleTurn().stream() + .map(bs -> InGameBattleResponse.BattleStoryResponse.builder() + .turnInfo(bs.getTurnInfo()) + .build()) + .toList() + : List.of()) + .playerWin(battleInfo != null ? battleInfo.getPlayerWin() : null) + .curTurnId(battleInfo != null ? battleInfo.getCurTurnId() : null) + .build(); } From 39e47998626a29ff51577463982026b3031dbe48 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 02:55:23 +0900 Subject: [PATCH 417/527] feat: add response to inGameData DONE --- .../ingame/InGameDoneResponse.java | 23 ++++++++++-- .../exception/GlobalExceptionHandler.java | 14 +++---- .../scriptopia/demo/mapper/InGameMapper.java | 3 ++ .../demo/service/GameSessionService.java | 37 +++++++++++++++++++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java index 886f10bd..25276557 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameDoneResponse.java @@ -22,7 +22,24 @@ public class InGameDoneResponse { private int progress; private int stageSize; - private InGamePlayerResponse playerInfo; // 외부 - private InGameNpcResponse npcInfo; // 외부 - private List inventory; // 외부 + private InGamePlayerResponse playerInfo; + private InGameNpcResponse npcInfo; + private List inventory; + + // 🏆 보상 정보 + private RewardInfoResponse rewardInfo; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RewardInfoResponse { + private List gainedItemNames; + private int rewardStrength; + private int rewardAgility; + private int rewardIntelligence; + private int rewardLuck; + private int rewardLife; + private int rewardGold; + } } diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index ed422c1d..e4d38d15 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -63,11 +63,11 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse(ErrorCode.E_500)); - } +// @ExceptionHandler(Exception.class) +// public ResponseEntity handleGeneralException(Exception ex) { +// +// return ResponseEntity +// .status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(new ErrorResponse(ErrorCode.E_500)); +// } } diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java index 0940e4f9..3b2dbc83 100644 --- a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -104,4 +104,7 @@ public List mapChoice(ChoiceInfoMongo choiceInfo) { .build()) .toList(); } + + + } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 5c884a38..8d257059 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.config.fastapi.FastApiClient; import com.scriptopia.demo.dto.gamesession.ingame.InGameBattleResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; +import com.scriptopia.demo.dto.gamesession.ingame.InGameDoneResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.mapper.InGameMapper; @@ -298,6 +299,42 @@ public Object getInGameDataDto(Long userId){ } else if (currentSceneType == SceneType.DONE) { + RewardInfoMongo rewardInfo = gameSessionMongo.getRewardInfo(); + + List gainedItemNames = List.of(); + if (rewardInfo != null && rewardInfo.getGainedItemDefId() != null) { + gainedItemNames = rewardInfo.getGainedItemDefId().stream() + .map(itemDefId -> itemDefMongoRepository.findById(itemDefId) + .map(ItemDefMongo::getName) + .orElse("Unknown Item")) + .toList(); + } + + + return InGameDoneResponse.builder() + .sceneType("DONE") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(gameSessionMongo.getUpdatedAt()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .progress(gameSessionMongo.getProgress()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) + .rewardInfo( + InGameDoneResponse.RewardInfoResponse.builder() + .gainedItemNames(gainedItemNames) + .rewardStrength(rewardInfo != null && rewardInfo.getRewardStrength() != null ? rewardInfo.getRewardStrength() : 0) + .rewardAgility(rewardInfo != null && rewardInfo.getRewardAgility() != null ? rewardInfo.getRewardAgility() : 0) + .rewardIntelligence(rewardInfo != null && rewardInfo.getRewardIntelligence() != null ? rewardInfo.getRewardIntelligence() : 0) + .rewardLuck(rewardInfo != null && rewardInfo.getRewardLuck() != null ? rewardInfo.getRewardLuck() : 0) + .rewardLife(rewardInfo != null && rewardInfo.getRewardLife() != null ? rewardInfo.getRewardLife() : 0) + .rewardGold(rewardInfo != null && rewardInfo.getRewardGold() != null ? rewardInfo.getRewardGold() : 0) + .build() + ) + .build(); + } else if (currentSceneType == SceneType.SHOP) { From 3b24e4034b1f929b62182b9473200fb13b34738d Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 02:57:36 +0900 Subject: [PATCH 418/527] sharedgamecontroller half edit --- .../demo/controller/SharedGameController.java | 11 ++++++----- .../com/scriptopia/demo/domain/SharedGame.java | 1 - .../demo/dto/sharedgame/SharedGameRequest.java | 7 ++----- .../service/SharedGameFavoriteService.java | 18 ++++++++++-------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 128f9d78..6727043e 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -1,8 +1,10 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; +import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; import lombok.RequiredArgsConstructor; @@ -26,10 +28,10 @@ public class SharedGameController { */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping - public ResponseEntity share(Authentication authentication, @PathVariable UUID uuid) { + public ResponseEntity share(Authentication authentication, @RequestBody SharedGameRequest req) { Long userId = Long.valueOf(authentication.getName()); - return sharedGameService.saveSharedGame(userId, uuid); + return sharedGameService.saveSharedGame(userId, req.getUuid()); } /* @@ -48,7 +50,7 @@ public ResponseEntity> getPublicSharedGames /* 게임공유 : 공유된 게임 상세 조회 */ - @GetMapping("{sharedGameId}") + @GetMapping("/{sharedGameId}") public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID sharedGameId) { return sharedGameService.getDetailedSharedGame(sharedGameId); } @@ -58,7 +60,7 @@ public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("{sharedGameId}/like") - public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") Long sharedGameId, Authentication authentication) { + public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") UUID sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); @@ -67,7 +69,6 @@ public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") Long share /* 게임공유 : 공유된 게임 태그 조회 */ - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @GetMapping("/tags") public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index b2ad6526..d7bda955 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.domain; -import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java index 929e6a14..25a2f758 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameRequest.java @@ -4,12 +4,9 @@ import lombok.Data; import java.time.LocalDateTime; +import java.util.UUID; @Data public class SharedGameRequest { - private Long userId; - private String thumbnail_url; - private String title; - private String world_view; - private String background_story; + private UUID uuid; } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java index 61d092c7..038a9d86 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; + @Service @RequiredArgsConstructor @@ -21,14 +23,14 @@ public class SharedGameFavoriteService { private final SharedGameScoreRepository sharedGameScoreRepository; @Transactional - public ResponseEntity saveFavorite(Long userId, Long sharedGameId) { + public ResponseEntity saveFavorite(Long userId, UUID uuid) { var user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - var game = sharedGameRepository.findById(sharedGameId) + var game = sharedGameRepository.findByUuid(uuid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); // 토글 처리 - var existing = sharedGameFavoriteRepository.findByUserIdAndSharedGameId(userId, sharedGameId); + var existing = sharedGameFavoriteRepository.findByUserIdAndSharedGameId(userId, game.getId()); boolean liked; if (existing.isPresent()) { sharedGameFavoriteRepository.delete(existing.get()); @@ -41,15 +43,15 @@ public ResponseEntity saveFavorite(Long userId, Long sharedGameId) { liked = true; } - long likeCount = sharedGameFavoriteRepository.countBySharedGameId(sharedGameId); - long playCount = sharedGameScoreRepository.countBySharedGameId(sharedGameId); - Long maxScore = sharedGameScoreRepository.maxScoreBySharedGameId(sharedGameId); + long likeCount = sharedGameFavoriteRepository.countBySharedGameId(game.getId()); + long playCount = sharedGameScoreRepository.countBySharedGameId(game.getId()); + Long maxScore = sharedGameScoreRepository.maxScoreBySharedGameId(game.getId()); // 태그 이름들 - var tagNames = gameTagRepository.findTagNamesBySharedGameId(sharedGameId); + var tagNames = gameTagRepository.findTagNamesBySharedGameId(game.getId()); var dto = new SharedGameFavoriteResponse(); - dto.setSharedGameId(sharedGameId); + dto.setSharedGameId(game.getId()); dto.setThumbnailUrl(game.getThumbnailUrl()); dto.setLiked(liked); dto.setLikeCount(likeCount); From c790a4b52ab2ae359837dad6f7ce2d69e0be45ea Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 03:32:00 +0900 Subject: [PATCH 419/527] sharedgameController edit --- .../demo/controller/SharedGameController.java | 4 ++-- .../demo/controller/TagDefController.java | 11 ++++------ .../demo/dto/TagDef/TagDefDeleteRequest.java | 2 +- .../scriptopia/demo/exception/ErrorCode.java | 6 ++++-- .../demo/repository/TagDefRepository.java | 3 +++ .../demo/service/TagDefService.java | 20 +++++++++---------- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 6727043e..efdc9709 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -79,10 +79,10 @@ public ResponseEntity getSharedGameTags() { */ @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping("/shared-games") - public ResponseEntity delete(Authentication authentication, @PathVariable UUID uuid) { + public ResponseEntity delete(Authentication authentication, @RequestBody SharedGameRequest req) { Long userId = Long.valueOf(authentication.getName()); - sharedGameService.deleteSharedGame(userId, uuid); + sharedGameService.deleteSharedGame(userId, req.getUuid()); return ResponseEntity.ok("게임이 삭제되었습니다."); } diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java index 843b9eb3..ae830c69 100644 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -16,17 +16,14 @@ public class TagDefController { @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping - public ResponseEntity addTag(@RequestBody TagDefCreateRequest req, Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { - return tagDefService.addTagName(req, userId); + return tagDefService.addTagName(req); } @PreAuthorize("hasAnyAuthority('ADMIN')") @DeleteMapping - public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req, Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return tagDefService.removeTagName(req, userId); + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { + return tagDefService.removeTagName(req); } } diff --git a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java index b4836f28..d0996d03 100644 --- a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java @@ -8,5 +8,5 @@ @AllArgsConstructor @NoArgsConstructor public class TagDefDeleteRequest { - private Long id; + private String name; } diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 42fc59cf..feeb70c1 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -2,6 +2,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; import org.springframework.http.HttpStatus; @Getter @@ -38,7 +39,8 @@ public enum ErrorCode { E_400_ITEM_NO_USES_LEFT("E400025", "아이템 사용 가능 횟수가 남아있지 않습니다.", HttpStatus.BAD_REQUEST), E_400_EMPTY_FILE("E400026", "파일이 비어있습니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), - E_400_INVALID_ENUM_TYPE("E4000028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), + E_400_INVALID_ENUM_TYPE("E400028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), + E_400_TAG_DUPLICATED("E400029", "중복된 태그입니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized @@ -72,7 +74,7 @@ public enum ErrorCode { E_404_ITEM_NOT_FOUND("E404009", "아이템이 없습니다.", HttpStatus.NOT_FOUND), E_404_PAGE_NOT_FOUND("E404010", "페이지가 없습니다.", HttpStatus.NOT_FOUND), E_404_SETTING_NOT_FOUND("E404011", "유저 설정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - + E_404_Tag_NOT_FOUND("E404012", "태그를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java index b04bc677..9dae6bae 100644 --- a/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/TagDefRepository.java @@ -4,7 +4,10 @@ import com.scriptopia.demo.domain.TagDef; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface TagDefRepository extends JpaRepository { boolean existsByTagName(String tagName); + Optional findByTagName(String tagName); } diff --git a/src/main/java/com/scriptopia/demo/service/TagDefService.java b/src/main/java/com/scriptopia/demo/service/TagDefService.java index 22c236cd..4f820349 100644 --- a/src/main/java/com/scriptopia/demo/service/TagDefService.java +++ b/src/main/java/com/scriptopia/demo/service/TagDefService.java @@ -3,20 +3,23 @@ import com.scriptopia.demo.domain.TagDef; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; +import com.scriptopia.demo.exception.CustomException; +import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.TagDefRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor public class TagDefService { private final TagDefRepository tagDefRepository; @Transactional - public ResponseEntity addTagName(TagDefCreateRequest req, Long id) { - // TODO - 들어온 토큰으로 관리자 인증 해야함 + public ResponseEntity addTagName(TagDefCreateRequest req) { String tagName = req.getTagName(); if(!tagDefRepository.existsByTagName(tagName)) { // 입력된 태그 이미 존재하는지 확인 @@ -27,18 +30,15 @@ public ResponseEntity addTagName(TagDefCreateRequest req, Long id) { return ResponseEntity.ok(tagDef); } - return ResponseEntity.ok("이미 존재하는 태그입니다."); + throw new CustomException(ErrorCode.E_400_TAG_DUPLICATED); } @Transactional - public ResponseEntity removeTagName(TagDefDeleteRequest req, Long id) { - // TODO - 들어온 토큰으로 관리자 인증 해야함 - - // TODO - 이미 사용중인 태그는 삭제못하도록 막아야함 - - Long ids = req.getId(); + public ResponseEntity removeTagName(TagDefDeleteRequest req) { + TagDef tag = tagDefRepository.findByTagName(req.getName()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_Tag_NOT_FOUND)); - tagDefRepository.deleteById(ids); + tagDefRepository.delete(tag); return ResponseEntity.ok("선택하신 태그가 삭제되었습니다."); } } From a2840ede3c433a5d285cc480567733fd3cc2dea6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 11:25:45 +0900 Subject: [PATCH 420/527] before pull reqeust --- .../java/com/scriptopia/demo/service/GameSessionService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 8d257059..5d56719c 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,5 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.config.fastapi.FastApiClient; import com.scriptopia.demo.dto.gamesession.ingame.InGameBattleResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameDoneResponse; From 4b54fb72dbab226c04ea1798d72219894cafc025 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:49:21 +0900 Subject: [PATCH 421/527] feat: add PiaItemDTO for implement getPiaItems API --- .../com/scriptopia/demo/dto/users/PiaItemDTO.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/users/PiaItemDTO.java diff --git a/src/main/java/com/scriptopia/demo/dto/users/PiaItemDTO.java b/src/main/java/com/scriptopia/demo/dto/users/PiaItemDTO.java new file mode 100644 index 00000000..df125513 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/PiaItemDTO.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.users; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PiaItemDTO { + private String name; + private Long quantity; +} From 1932087af27560724eab42c94e9a884244e7cc83 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:50:54 +0900 Subject: [PATCH 422/527] feat: add API endpoint to retrieve user items (UserController) --- .../demo/controller/UserController.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 45ee1870..1f5f64b4 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.service.UserService; @@ -10,6 +11,8 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/users/me") @RequiredArgsConstructor @@ -17,6 +20,17 @@ public class UserController { private final UserService userService; + + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @GetMapping("/items/pia") + public ResponseEntity> getPiaItems( + Authentication authentication + ) { + String userId = authentication.getName(); + List response = userService.getPiaItems(userId); + return ResponseEntity.ok(response); + } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/settings") public ResponseEntity getUserSettings( @@ -48,4 +62,6 @@ public ResponseEntity getUserAssets( return ResponseEntity.ok(response); } + + } From e6f465ab2d83cb9c05399e9bf023e417a822c231 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:50:55 +0900 Subject: [PATCH 423/527] feat: implement query method for fetching user items (UserPiaItemRepository) --- .../com/scriptopia/demo/repository/UserPiaItemRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java index e6061fba..4f162d5a 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserPiaItemRepository.java @@ -5,8 +5,11 @@ import com.scriptopia.demo.domain.UserPiaItem; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserPiaItemRepository extends JpaRepository { Optional findByUserAndPiaItem(User user, PiaItem piaItem); + + List findByUserId(Long UserId); } From 2cbedb6460a541ed1c2ccbb1ffc7a6d61a5bd2ed Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:50:55 +0900 Subject: [PATCH 424/527] feat: add business logic for retrieving user items (UserService) --- .../scriptopia/demo/service/UserService.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index d7d38418..1434b437 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -1,11 +1,14 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserPiaItem; import com.scriptopia.demo.domain.UserSetting; +import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.repository.UserPiaItemRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; @@ -13,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Service @RequiredArgsConstructor @@ -21,6 +26,7 @@ public class UserService { private final UserSettingRepository userSettingRepository; private final UserRepository userRepository; + private final UserPiaItemRepository userPiaItemRepository; @Transactional public UserSettingsDTO getUserSettings(String userId){ @@ -57,7 +63,6 @@ public void updateUserSettings(String userId, UserSettingsDTO request){ @Transactional public UserAssetsResponse getUserAssets(String userId){ - User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) ); @@ -68,6 +73,24 @@ public UserAssetsResponse getUserAssets(String userId){ } + @Transactional + public List getPiaItems(String userId){ + User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + List piaItems = new ArrayList<>(); + for (UserPiaItem userPiaItem : userPiaItemRepository.findByUserId(Long.valueOf(userId))) { + piaItems.add( + PiaItemDTO.builder() + .name(userPiaItem.getPiaItem().getName()) + .quantity(userPiaItem.getQuantity()) + .build() + ); + } + + return piaItems; + } + } From 992be90d935c7cfaba8df12a3388980e19f44711 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 12:02:40 +0900 Subject: [PATCH 425/527] before origin merging --- .../demo/config/SecurityWhitelist.java | 2 + .../demo/controller/TestEnvController.java | 13 ++ src/main/resources/application.yml | 4 + src/main/resources/templates/index.html | 116 +++++++++++++++++- 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/controller/TestEnvController.java diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index 7128cc72..d3f7811f 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -3,6 +3,8 @@ public class SecurityWhitelist { public static final String[] AUTH_WHITELIST = { + "/", + "/error", "/auth/logout", diff --git a/src/main/java/com/scriptopia/demo/controller/TestEnvController.java b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java new file mode 100644 index 00000000..314f7de0 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java @@ -0,0 +1,13 @@ +package com.scriptopia.demo.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + + +@Controller +public class TestEnvController { + @GetMapping("/") + public String mainPage() { + return "index"; // templates/index.html + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70b92ee8..46a69591 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,8 @@ spring: + thymeleaf: + prefix: classpath:/templates/ + suffix: .html + config: import: optional:file:.env[.properties] datasource: diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 12b283c7..8abc0285 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,10 +1,120 @@ - - Scriptopia! + Scriptopia - Main + + -

Hello, Scriptopia!

+

Welcome to Scriptopia!

+ +
+
+
+
+ +
+ +
+ +
+ + + + + From a953e96fb580f98af24c40ca7b8ce957f4decab9 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:26:21 +0900 Subject: [PATCH 426/527] feat: Change ItemController field type from int to Integer to allow null values --- .../com/scriptopia/demo/controller/ItemController.java | 7 +++---- .../demo/service/{ItemDefService.java => ItemService.java} | 0 2 files changed, 3 insertions(+), 4 deletions(-) rename src/main/java/com/scriptopia/demo/service/{ItemDefService.java => ItemService.java} (100%) diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index 1ee7cfe3..d75d0ddf 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,8 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.items.ItemDefRequest; -import com.scriptopia.demo.dto.items.ItemFastApiResponse; -import com.scriptopia.demo.service.ItemDefService; +import com.scriptopia.demo.service.ItemService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -12,12 +11,12 @@ @RequiredArgsConstructor public class ItemController { - private final ItemDefService itemDefService; + private final ItemService itemService; @PostMapping public ResponseEntity createItem(@RequestBody ItemDefRequest request) { - String savedItem = itemDefService.createItem(request); + String savedItem = itemService.createMongoItem(request); return ResponseEntity.ok(savedItem); } diff --git a/src/main/java/com/scriptopia/demo/service/ItemDefService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java similarity index 100% rename from src/main/java/com/scriptopia/demo/service/ItemDefService.java rename to src/main/java/com/scriptopia/demo/service/ItemService.java From 569958f22682a96e6357f2cceef2223b3f4d4bf0 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:26:21 +0900 Subject: [PATCH 427/527] feat: Update InGameNpcResponse data type from int to Integer to support nullable fields --- .../demo/dto/gamesession/ingame/InGameNpcResponse.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java index 9b9007c8..4daaddb4 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameNpcResponse.java @@ -11,12 +11,12 @@ @Builder public class InGameNpcResponse { private String name; - private int rank; + private Integer rank; private String trait; - private int strength; - private int agility; - private int intelligence; - private int luck; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; private String npcWeaponName; private String npcWeaponDescription; From d05081850739f2ac3032d06473eee855f149ecc7 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:26:22 +0900 Subject: [PATCH 428/527] feat: Refactor GameSessionService to separate fast API response handling and RDBMS/MongoDB persistence --- .../java/com/scriptopia/demo/service/GameSessionService.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 5d56719c..b5a67f2c 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -25,7 +25,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.awt.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -41,7 +40,7 @@ public class GameSessionService { private final GameSessionMongoRepository gameSessionMongoRepository; private final UserItemRepository userItemRepository; private final FastApiService fastApiService; - private final ItemDefService itemDefService; + private final ItemService itemService; private final InGameMapper inGameMapper; @@ -892,7 +891,7 @@ private RewardInfoMongo handleReward(GameSessionMongo gameSessionMongo, RewardTy .previousStory(gameSessionMongo.getBackground()) .build(); - String itemMongoId = itemDefService.createItem(itemDefRequest); + String itemMongoId = itemService.createMongoItem(itemDefRequest); List gainItemList = rewardInfo.getGainedItemDefId(); if (gainItemList == null) { gainItemList = new ArrayList<>(); // null이면 새 리스트 생성 From ab5e6707d940ef06fee3ca2e27e31cc6bca8a826 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:26:23 +0900 Subject: [PATCH 429/527] feat: Split fast API response handling from persistence logic in ItemService for better structure --- .../scriptopia/demo/service/ItemService.java | 108 ++++++++---------- 1 file changed, 48 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/ItemService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java index 79ecdac7..686f9ef8 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemService.java @@ -10,23 +10,19 @@ import com.scriptopia.demo.repository.ItemDefRepository; import com.scriptopia.demo.repository.ItemGradeDefRepository; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; -import com.scriptopia.demo.utils.GameBalanceUtil; +import com.scriptopia.demo.utils.InitItemData; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ItemDefService { +public class ItemService { private final ItemDefRepository itemDefRepository; private final ItemGradeDefRepository itemGradeDefRepository; @@ -35,13 +31,9 @@ public class ItemDefService { private final FastApiService fastApiService; - /** - * mongoDB, RDB에 저장 후 mongoDB의 item_Def_id를 리턴 - * @param request - * @return - */ - @Transactional(readOnly = false) - public String createItem(ItemDefRequest request) { + + @Transactional + public ItemFastApiResponse createItemInit(ItemDefRequest request, InitItemData initItemData){ /** * 1. 카테고리 * 2. 등급 @@ -50,46 +42,19 @@ public String createItem(ItemDefRequest request) { * 5. 아이템 이펙트( 최대 등급 3개) * 6. 추가 스탯 */ - ItemType itemCategory = ItemType.getRandomItemType(); - Grade itemGrade = Grade.getRandomGradeByProbability(); - int baseStat = Grade.getRandomBaseStat(itemCategory, itemGrade); - Stat mainStat = Stat.getRandomMainStat(); - int[] additionalStats = GameBalanceUtil.getRandomItemStatsByGrade(itemGrade); // strength, agility, intelligence, luck - - - List effectGrades = new ArrayList<>(); - List effectGradesList = new ArrayList<>(); - for (int i = 0; i < 3; i++) { - EffectProbability effectGrade = EffectProbability.getRandomEffectGradeByWeaponGrade(itemGrade); - if (effectGrade != null) { - Long effectPrice = effectGradeDefRepository.findPriceByEffectProbability(effectGrade) - .orElseThrow(() -> new IllegalStateException("EffectGradeDef not found: " + effectGrade)); - - effectGradesList.add(effectPrice); - effectGrades.add(effectGrade); - // effectGradesList.add(effectGradeDefRepository.findPriceByEffectProbability(effectGrade).get()); - } - } - - System.out.println(effectGrades); - Long gradeGradePrice = itemGradeDefRepository.findPriceByGrade(itemGrade); - Long itemPrice = GameBalanceUtil.getItemPriceByGrade(gradeGradePrice, effectGradesList); - - - ItemFastApiRequest fastRequest = ItemFastApiRequest.builder() .worldView(request.getWorldView()) .location(request.getLocation()) - .category(itemCategory) - .baseStat(baseStat) - .mainStat(mainStat) - .grade(itemGrade) - .itemEffect(effectGrades) - .strength(additionalStats[0]) - .agility(additionalStats[1]) - .intelligence(additionalStats[2]) - .luck(additionalStats[3]) - .price(itemPrice) + .category(initItemData.getItemType()) + .baseStat(initItemData.getBaseStat()) + .mainStat(initItemData.getMainStat()) + .grade(initItemData.getGrade()) + .itemEffect(initItemData.getEffectGrades()) + .strength(initItemData.getStats()[0]) + .agility(initItemData.getStats()[1]) + .intelligence(initItemData.getStats()[2]) + .luck(initItemData.getStats()[3]) + .price(initItemData.getItemPrice()) .playerTrait(request.getPlayerTrait()) .previousStory(request.getPreviousStory()) .build(); @@ -100,11 +65,33 @@ public String createItem(ItemDefRequest request) { if (response == null) { throw new CustomException(ErrorCode.E_500_EXTERNAL_API_ERROR); } + return response; + } + + + + /** + * mongoDB, RDB에 저장 후 mongoDB의 item_Def_id를 리턴 + * @param request + * @return + */ + @Transactional(readOnly = false) + public String createMongoItem(ItemDefRequest request) { + + //아이템 초기 수치 생성 + InitItemData initItemData = new InitItemData(itemGradeDefRepository, effectGradeDefRepository); + + //fastAPI 통해서 아이템 생성 + ItemFastApiResponse response = createItemInit(request, initItemData); List mongoEffects = new ArrayList<>(); List apiEffects = response.getItemEffect(); + List effectGrades = initItemData.getEffectGrades(); + + + for (int i = 0; i < apiEffects.size(); i++) { ItemFastApiResponse.ItemEffect apiEffect = apiEffects.get(i); EffectProbability effectGrade = i < effectGrades.size() ? effectGrades.get(i) : null; @@ -120,24 +107,25 @@ public String createItem(ItemDefRequest request) { .itemPicSrc("test link") .name(response.getItemName()) .description(response.getItemDescription()) - .category(itemCategory) - .baseStat(baseStat) + .category(initItemData.getItemType()) + .baseStat(initItemData.getBaseStat()) .itemEffect(mongoEffects) - .strength(additionalStats[0]) - .agility(additionalStats[1]) - .intelligence(additionalStats[2]) - .luck(additionalStats[3]) - .mainStat(mainStat) - .grade(itemGrade) - .price(itemPrice) + .strength(initItemData.getStats()[0]) + .agility(initItemData.getStats()[1]) + .intelligence(initItemData.getStats()[2]) + .luck(initItemData.getStats()[3]) + .mainStat(initItemData.getMainStat()) + .grade(initItemData.getGrade()) + .price(initItemData.getItemPrice()) .build(); + itemDefMongoRepository.save(itemDefMongo); ItemDef itemDefRdb = new ItemDef(); itemDefRdb.setName(itemDefMongo.getName()); itemDefRdb.setDescription(itemDefMongo.getDescription()); - itemDefRdb.setItemGradeDef(itemGradeDefRepository.findByGrade(itemGrade).get()); + itemDefRdb.setItemGradeDef(itemGradeDefRepository.findByGrade(initItemData.getGrade()).get()); itemDefRdb.setPicSrc(itemDefMongo.getItemPicSrc()); itemDefRdb.setItemType(itemDefMongo.getCategory()); itemDefRdb.setBaseStat(itemDefMongo.getBaseStat()); From bb6402a7497c0a7f02bfbfaa1f21474f60865d7d Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:26:23 +0900 Subject: [PATCH 430/527] feat: Add InitItemData utility class to centralize fast API response storage in RDBMS and MongoDB --- .../scriptopia/demo/utils/InitItemData.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/utils/InitItemData.java diff --git a/src/main/java/com/scriptopia/demo/utils/InitItemData.java b/src/main/java/com/scriptopia/demo/utils/InitItemData.java new file mode 100644 index 00000000..e409e1d3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/utils/InitItemData.java @@ -0,0 +1,55 @@ +package com.scriptopia.demo.utils; + +import com.scriptopia.demo.domain.EffectProbability; +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.Stat; +import com.scriptopia.demo.repository.EffectGradeDefRepository; +import com.scriptopia.demo.repository.ItemGradeDefRepository; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class InitItemData { + private ItemType itemType; + private Grade grade; + private Integer baseStat; + private Stat mainStat; + private int[] stats; + private List effectGrades; + private List effectPrices; + private Long gradePrice; + private Long itemPrice; + + public InitItemData(ItemGradeDefRepository itemGradeDefRepository, + EffectGradeDefRepository effectGradeDefRepository) { + + // 아이템 타입, 등급, 기본 스탯, 메인 스탯 + this.itemType = ItemType.getRandomItemType(); + this.grade = Grade.getRandomGradeByProbability(); + this.baseStat = Grade.getRandomBaseStat(itemType, grade); + this.mainStat = Stat.getRandomMainStat(); + + // 추가 스탯 + this.stats = GameBalanceUtil.getRandomItemStatsByGrade(grade); + + // 이펙트 스탯 및 가격 + this.effectGrades = new ArrayList<>(); + this.effectPrices = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + EffectProbability effectGrade = EffectProbability.getRandomEffectGradeByWeaponGrade(grade); + if (effectGrade != null) { + Long effectPrice = effectGradeDefRepository.findPriceByEffectProbability(effectGrade) + .orElseThrow(() -> new IllegalStateException("EffectGradeDef not found: " + effectGrade)); + this.effectGrades.add(effectGrade); + this.effectPrices.add(effectPrice); + } + } + + // 등급 가격, 최종 아이템 가격 + this.gradePrice = itemGradeDefRepository.findPriceByGrade(grade); + this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice, effectPrices); + } +} From b281217f068f0e601ebd2dd2762fbdecf0ad0c0c Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 17:27:42 +0900 Subject: [PATCH 431/527] Add test Thymeleaf template for index page --- src/main/resources/templates/index.html | 291 ++++++++++++++++-------- 1 file changed, 193 insertions(+), 98 deletions(-) diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 8abc0285..ef3026d7 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,120 +1,215 @@ + Scriptopia - Main -

Welcome to Scriptopia!

+
-
-
-
-
- -
+ +
+
+

로그인

+
+
+
+ +
+
+ 로그인 토큰: +
+ +
+
-
- -
+ +
+ +
+
+

Player Info

+
HP: 100
MP: 50
Level: 1
+
+
+

Inventory

+
아이템 없음
+
+
-
- From 085a1a949e9bb650d158fc2214219e4cb9676cf8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 17:27:42 +0900 Subject: [PATCH 432/527] Add test controller for Thymeleaf experiment --- .../java/com/scriptopia/demo/controller/TestEnvController.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/TestEnvController.java b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java index 314f7de0..759c16e1 100644 --- a/src/main/java/com/scriptopia/demo/controller/TestEnvController.java +++ b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java @@ -1,7 +1,9 @@ package com.scriptopia.demo.controller; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; @Controller @@ -10,4 +12,5 @@ public class TestEnvController { public String mainPage() { return "index"; // templates/index.html } + } From d6a5250746e5a2fdffaca8b5cafeb15280252d07 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 17:27:43 +0900 Subject: [PATCH 433/527] Update GameSessionService: set NPCINFO to NULL for NONLIVING in gameToChoice --- .../java/com/scriptopia/demo/service/GameSessionService.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 5d56719c..c1273bb1 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -501,7 +501,6 @@ public GameSessionMongo gameToChoice(Long userId) { }).toList(); fastApiRequest.setItemInfo(itemInfoList); - System.out.println("asdasd " + fastApiRequest); CreateGameChoiceResponse createGameChoiceResponse = fastApiService.makeChoice(fastApiRequest); if (createGameChoiceResponse == null) { @@ -513,10 +512,10 @@ public GameSessionMongo gameToChoice(Long userId) { gameSessionMongo.setBackground(createGameChoiceResponse.getChoiceInfo().getStory()); gameSessionMongo.setProgress(gameSessionMongo.getProgress()); - + NpcInfoMongo npcInfoMongo = null; if (currentNpcRank > 0){ int[] npcStat = GameBalanceUtil.getNpcStatsByRank(currentNpcRank); - NpcInfoMongo npcInfoMongo = NpcInfoMongo.builder() + npcInfoMongo = NpcInfoMongo.builder() .rank(currentNpcRank) .name(createGameChoiceResponse.getNpcInfo().getName()) .trait(createGameChoiceResponse.getNpcInfo().getTrait()) From acb99e757b15a4c76cc7d43a1d1b09e5b051cafd Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:24 +0900 Subject: [PATCH 434/527] feat(item): update ItemController to integrate item creation with FastAPI response and refactored request handling --- .../demo/controller/ItemController.java | 16 +++- .../demo/domain/EffectGradeDef.java | 1 + .../demo/dto/items/ItemDefRequest.java | 2 + .../demo/dto/items/ItemFastApiResponse.java | 2 +- .../demo/service/GameSessionService.java | 6 +- .../scriptopia/demo/service/ItemService.java | 73 +++++++++++-------- 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index d75d0ddf..cecdbb2c 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -1,23 +1,31 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.service.ItemService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/public/items") +@RequestMapping("/items") @RequiredArgsConstructor public class ItemController { private final ItemService itemService; + @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping - public ResponseEntity createItem(@RequestBody ItemDefRequest request) { - String savedItem = itemService.createMongoItem(request); - return ResponseEntity.ok(savedItem); + public ResponseEntity createItem( + Authentication authentication, + @RequestBody ItemDefRequest request + ) { + String userId = authentication.getName(); + ItemDTO itemInWeb = itemService.createItemInWeb(userId, request); + return ResponseEntity.ok(itemInWeb); } diff --git a/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java b/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java index 0e5d2fe0..b3bfe213 100644 --- a/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java +++ b/src/main/java/com/scriptopia/demo/domain/EffectGradeDef.java @@ -19,4 +19,5 @@ public class EffectGradeDef { @Enumerated(EnumType.STRING) private EffectProbability effectProbability; + } diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java index 88d7fcc9..dc7816b6 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDefRequest.java @@ -12,6 +12,8 @@ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemDefRequest { private String worldView; diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java index f4389499..8b34d63a 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiResponse.java @@ -13,7 +13,7 @@ public class ItemFastApiResponse { private String itemName; private String itemDescription; - private List itemEffect; + private List itemEffects; @Data @NoArgsConstructor diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index b5a67f2c..afa39869 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -858,8 +858,8 @@ private CreateGameBattleRequest.Item mapToItemEffect(ItemDefMongo item) { private ItemDefMongo convertToItemDefMongo(ItemFastApiResponse response) { List effects = new ArrayList<>(); - if (response.getItemEffect() != null) { - for (ItemFastApiResponse.ItemEffect e : response.getItemEffect()) { + if (response.getItemEffects() != null) { + for (ItemFastApiResponse.ItemEffect e : response.getItemEffects()) { ItemEffectMongo effectMongo = ItemEffectMongo.builder() .itemEffectName(e.getItemEffectName()) .itemEffectDescription(e.getItemEffectDescription()) @@ -891,7 +891,7 @@ private RewardInfoMongo handleReward(GameSessionMongo gameSessionMongo, RewardTy .previousStory(gameSessionMongo.getBackground()) .build(); - String itemMongoId = itemService.createMongoItem(itemDefRequest); + String itemMongoId = itemService.createItemInGame(itemDefRequest); List gainItemList = rewardInfo.getGainedItemDefId(); if (gainItemList == null) { gainItemList = new ArrayList<>(); // null이면 새 리스트 생성 diff --git a/src/main/java/com/scriptopia/demo/service/ItemService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java index 686f9ef8..1c2ab1a2 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemService.java @@ -31,9 +31,8 @@ public class ItemService { private final FastApiService fastApiService; - @Transactional - public ItemFastApiResponse createItemInit(ItemDefRequest request, InitItemData initItemData){ + public ItemFastApiResponse createItemInit(ItemDefRequest request, InitItemData initItemData) { /** * 1. 카테고리 * 2. 등급 @@ -69,14 +68,14 @@ public ItemFastApiResponse createItemInit(ItemDefRequest request, InitItemData i } - /** * mongoDB, RDB에 저장 후 mongoDB의 item_Def_id를 리턴 + * * @param request * @return */ @Transactional(readOnly = false) - public String createMongoItem(ItemDefRequest request) { + public String createItemInGame(ItemDefRequest request) { //아이템 초기 수치 생성 InitItemData initItemData = new InitItemData(itemGradeDefRepository, effectGradeDefRepository); @@ -85,24 +84,25 @@ public String createMongoItem(ItemDefRequest request) { ItemFastApiResponse response = createItemInit(request, initItemData); + // 생성한 아이템 효과 MongoDB 매핑 List mongoEffects = new ArrayList<>(); - List apiEffects = response.getItemEffect(); - - List effectGrades = initItemData.getEffectGrades(); + List createdItemEffects = response.getItemEffects(); + List effectProbabilities = initItemData.getEffectGrades(); - - for (int i = 0; i < apiEffects.size(); i++) { - ItemFastApiResponse.ItemEffect apiEffect = apiEffects.get(i); - EffectProbability effectGrade = i < effectGrades.size() ? effectGrades.get(i) : null; + for (int i = 0; i < createdItemEffects.size(); i++) { + ItemFastApiResponse.ItemEffect createdEffect = createdItemEffects.get(i); + EffectProbability effectProbability = i < effectProbabilities.size() ? effectProbabilities.get(i) : null; mongoEffects.add(ItemEffectMongo.builder() - .effectProbability(effectGrade != null ? (effectGrade) : EffectProbability.COMMON) - .itemEffectName(apiEffect.getItemEffectName()) - .itemEffectDescription(apiEffect.getItemEffectDescription()) + .effectProbability(effectProbability != null ? (effectProbability) : EffectProbability.COMMON) + .itemEffectName(createdEffect.getItemEffectName()) + .itemEffectDescription(createdEffect.getItemEffectDescription()) .build()); } + + //아이템 정보 MongoDB 매핑 ItemDefMongo itemDefMongo = ItemDefMongo.builder() .itemPicSrc("test link") .name(response.getItemName()) @@ -119,33 +119,37 @@ public String createMongoItem(ItemDefRequest request) { .price(initItemData.getItemPrice()) .build(); - itemDefMongoRepository.save(itemDefMongo); + + //아이템 정보 RDBMS 매핑 ItemDef itemDefRdb = new ItemDef(); - itemDefRdb.setName(itemDefMongo.getName()); - itemDefRdb.setDescription(itemDefMongo.getDescription()); + itemDefRdb.setName(response.getItemName()); + itemDefRdb.setDescription(response.getItemDescription()); itemDefRdb.setItemGradeDef(itemGradeDefRepository.findByGrade(initItemData.getGrade()).get()); - itemDefRdb.setPicSrc(itemDefMongo.getItemPicSrc()); - itemDefRdb.setItemType(itemDefMongo.getCategory()); - itemDefRdb.setBaseStat(itemDefMongo.getBaseStat()); - itemDefRdb.setStrength(itemDefMongo.getStrength()); - itemDefRdb.setAgility(itemDefMongo.getAgility()); - itemDefRdb.setIntelligence(itemDefMongo.getIntelligence()); - itemDefRdb.setLuck(itemDefMongo.getLuck()); - itemDefRdb.setMainStat(itemDefMongo.getMainStat()); - itemDefRdb.setPrice(itemDefMongo.getPrice()); + itemDefRdb.setPicSrc("test link"); // Mongo 참조 대신 직접 값 지정 + itemDefRdb.setItemType(initItemData.getItemType()); + itemDefRdb.setBaseStat(initItemData.getBaseStat()); + itemDefRdb.setStrength(initItemData.getStats()[0]); + itemDefRdb.setAgility(initItemData.getStats()[1]); + itemDefRdb.setIntelligence(initItemData.getStats()[2]); + itemDefRdb.setLuck(initItemData.getStats()[3]); + itemDefRdb.setMainStat(initItemData.getMainStat()); + itemDefRdb.setPrice(initItemData.getItemPrice()); itemDefRdb.setCreatedAt(LocalDateTime.now()); List rdbEffects = new ArrayList<>(); - for (ItemEffectMongo effectMongo : itemDefMongo.getItemEffect()) { + + //아이템 효과 정보 RDBMS 매핑 + for (int i = 0; i < effectProbabilities.size(); i++) { ItemEffect effect = new ItemEffect(); effect.setItemDef(itemDefRdb); - effect.setEffectName(effectMongo.getItemEffectName()); - effect.setEffectDescription(effectMongo.getItemEffectDescription()); - effect.setEffectGradeDef(effectGradeDefRepository.findByEffectProbability(effectMongo.getEffectProbability()).get()); + effect.setEffectName(createdItemEffects.get(i).getItemEffectName()); + effect.setEffectDescription(createdItemEffects.get(i).getItemEffectDescription()); + effect.setEffectGradeDef(effectGradeDefRepository.findByEffectProbability(effectProbabilities.get(i)).get()); rdbEffects.add(effect); } + itemDefRdb.setItemEffects(rdbEffects); itemDefRepository.save(itemDefRdb); @@ -153,6 +157,15 @@ public String createMongoItem(ItemDefRequest request) { return itemDefMongo.getId(); } + public String createItemInWeb(ItemDefRequest request) { + + InitItemData initItemData = new InitItemData(itemGradeDefRepository, effectGradeDefRepository); + + //fastAPI 통해서 아이템 생성 + ItemFastApiResponse response = createItemInit(request, initItemData); + + + } } \ No newline at end of file From fa0a63f7eea8f0e6f31c178b96125c30216a6cd7 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:25 +0900 Subject: [PATCH 435/527] feat(dto): enhance ItemFastApiRequest to support new item creation fields for FastAPI integration --- .../java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java index b916fdd0..6a37658e 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemFastApiRequest.java @@ -20,7 +20,7 @@ public class ItemFastApiRequest { private int baseStat; private Stat mainStat; private Grade grade; - private List itemEffect; + private List itemEffects; private int strength; private int agility; private int intelligence; From d57bb752ba5574bcff909271f151b32baabe3f68 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:25 +0900 Subject: [PATCH 436/527] feat(service): refactor ItemService to map FastAPI response into RDBMS and MongoDB with separated logic --- .../scriptopia/demo/service/ItemService.java | 82 +++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/ItemService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java index 1c2ab1a2..83f54fff 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemService.java @@ -6,9 +6,7 @@ import com.scriptopia.demo.dto.items.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; -import com.scriptopia.demo.repository.EffectGradeDefRepository; -import com.scriptopia.demo.repository.ItemDefRepository; -import com.scriptopia.demo.repository.ItemGradeDefRepository; +import com.scriptopia.demo.repository.*; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; import com.scriptopia.demo.utils.InitItemData; import lombok.RequiredArgsConstructor; @@ -29,6 +27,8 @@ public class ItemService { private final EffectGradeDefRepository effectGradeDefRepository; private final ItemDefMongoRepository itemDefMongoRepository; private final FastApiService fastApiService; + private final UserItemRepository userItemRepository; + private final UserRepository userRepository; @Transactional @@ -48,7 +48,7 @@ public ItemFastApiResponse createItemInit(ItemDefRequest request, InitItemData i .baseStat(initItemData.getBaseStat()) .mainStat(initItemData.getMainStat()) .grade(initItemData.getGrade()) - .itemEffect(initItemData.getEffectGrades()) + .itemEffects(initItemData.getEffectGrades()) .strength(initItemData.getStats()[0]) .agility(initItemData.getStats()[1]) .intelligence(initItemData.getStats()[2]) @@ -138,6 +138,8 @@ public String createItemInGame(ItemDefRequest request) { itemDefRdb.setPrice(initItemData.getItemPrice()); itemDefRdb.setCreatedAt(LocalDateTime.now()); + + List rdbEffects = new ArrayList<>(); //아이템 효과 정보 RDBMS 매핑 @@ -157,13 +159,83 @@ public String createItemInGame(ItemDefRequest request) { return itemDefMongo.getId(); } - public String createItemInWeb(ItemDefRequest request) { + @Transactional + public ItemDTO createItemInWeb(String userId, ItemDefRequest request) { + + User user = userRepository.findById(Long.valueOf(userId)).get(); InitItemData initItemData = new InitItemData(itemGradeDefRepository, effectGradeDefRepository); //fastAPI 통해서 아이템 생성 ItemFastApiResponse response = createItemInit(request, initItemData); + List effectProbabilities = initItemData.getEffectGrades(); + List createdItemEffects = response.getItemEffects(); + + //아이템 정보 RDBMS 매핑 + ItemDef itemDefRdb = new ItemDef(); + itemDefRdb.setName(response.getItemName()); + itemDefRdb.setDescription(response.getItemDescription()); + itemDefRdb.setItemGradeDef(itemGradeDefRepository.findByGrade(initItemData.getGrade()).get()); + itemDefRdb.setPicSrc("test link"); // Mongo 참조 대신 직접 값 지정 + itemDefRdb.setItemType(initItemData.getItemType()); + itemDefRdb.setBaseStat(initItemData.getBaseStat()); + itemDefRdb.setStrength(initItemData.getStats()[0]); + itemDefRdb.setAgility(initItemData.getStats()[1]); + itemDefRdb.setIntelligence(initItemData.getStats()[2]); + itemDefRdb.setLuck(initItemData.getStats()[3]); + itemDefRdb.setMainStat(initItemData.getMainStat()); + itemDefRdb.setPrice(initItemData.getItemPrice()); + itemDefRdb.setCreatedAt(LocalDateTime.now()); + + List rdbEffects = new ArrayList<>(); + + System.out.println(effectProbabilities); + System.out.println(createdItemEffects); + //아이템 효과 정보 RDBMS 매핑 + for (int i = 0; i < effectProbabilities.size(); i++) { + ItemEffect effect = new ItemEffect(); + effect.setItemDef(itemDefRdb); + effect.setEffectName(createdItemEffects.get(i).getItemEffectName()); + effect.setEffectDescription(createdItemEffects.get(i).getItemEffectDescription()); + effect.setEffectGradeDef(effectGradeDefRepository.findByEffectProbability(effectProbabilities.get(i)).get()); + rdbEffects.add(effect); + } + itemDefRepository.save(itemDefRdb); + + UserItem userItem = new UserItem(); + userItem.setItemDef(itemDefRdb); + userItem.setUser(user); + userItem.setRemainingUses(initItemData.getRemainingUses()); + userItem.setTradeStatus(TradeStatus.OWNED); + + userItemRepository.save(userItem); + + + List effects = rdbEffects.stream() + .map(e -> ItemEffectDTO.builder() + .effectProbability(e.getEffectGradeDef().getEffectProbability()) // enum/객체 그대로 매핑 + .effectName(e.getEffectName()) + .description(e.getEffectDescription()) + .build()) + .toList(); + + return ItemDTO.builder() + .name(response.getItemName()) + .description(response.getItemDescription()) + .picSrc("test link") + .itemType(initItemData.getItemType()) + .baseStat(initItemData.getBaseStat()) + .strength(initItemData.getStats()[0]) + .agility(initItemData.getStats()[1]) + .intelligence(initItemData.getStats()[2]) + .luck(initItemData.getStats()[3]) + .mainStat(initItemData.getMainStat()) + .grade(initItemData.getGrade()) + .itemEffect(effects) // 리스트 주입 + .remainingUses(initItemData.getRemainingUses()) + .price(initItemData.getItemPrice()) + .build(); } From 7ad84a50690486bed4cef120b69c24afcb6ecba2 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:26 +0900 Subject: [PATCH 437/527] feat(utils): extend InitItemData to centralize initialization of item stats and effect grades --- src/main/java/com/scriptopia/demo/utils/InitItemData.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/utils/InitItemData.java b/src/main/java/com/scriptopia/demo/utils/InitItemData.java index e409e1d3..3ed6c2cb 100644 --- a/src/main/java/com/scriptopia/demo/utils/InitItemData.java +++ b/src/main/java/com/scriptopia/demo/utils/InitItemData.java @@ -22,6 +22,7 @@ public class InitItemData { private List effectPrices; private Long gradePrice; private Long itemPrice; + private Integer remainingUses; public InitItemData(ItemGradeDefRepository itemGradeDefRepository, EffectGradeDefRepository effectGradeDefRepository) { @@ -51,5 +52,6 @@ public InitItemData(ItemGradeDefRepository itemGradeDefRepository, // 등급 가격, 최종 아이템 가격 this.gradePrice = itemGradeDefRepository.findPriceByGrade(grade); this.itemPrice = GameBalanceUtil.getItemPriceByGrade(gradePrice, effectPrices); + this.remainingUses = 5; } } From f1494c8ae5a2f12f5b205847824619012f0e227b Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:26 +0900 Subject: [PATCH 438/527] feat(dto): introduce ItemDTO with builder pattern to unify item response structure --- .../scriptopia/demo/dto/items/ItemDTO.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java new file mode 100644 index 00000000..07cc57e3 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java @@ -0,0 +1,32 @@ +package com.scriptopia.demo.dto.items; + +import com.scriptopia.demo.domain.Grade; +import com.scriptopia.demo.domain.ItemType; +import com.scriptopia.demo.domain.Stat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ItemDTO { + private String name; + private String description; + private String picSrc; + private ItemType itemType; // WEAPON, ARMOR, ARTIFACT + private Integer baseStat; + private Integer strength; + private Integer agility; + private Integer intelligence; + private Integer luck; + private Stat mainStat; // STRENGTH, AGILITY, INTELLIGENCE, LUCK + private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY + private List itemEffect; + private Integer remainingUses; + private Long price; +} From 2dc22892c41c0220ddd7b0fda035b2c3c20189c7 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:27 +0900 Subject: [PATCH 439/527] feat(dto): add ItemEffectDTO for structured representation of item effect data --- .../demo/dto/items/ItemEffectDTO.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/items/ItemEffectDTO.java diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemEffectDTO.java b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectDTO.java new file mode 100644 index 00000000..54233867 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemEffectDTO.java @@ -0,0 +1,17 @@ +package com.scriptopia.demo.dto.items; + +import com.scriptopia.demo.domain.EffectProbability; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ItemEffectDTO { + private EffectProbability effectProbability; + private String effectName; + private String description; +} From 6b949f2e665f1391a9fa60ca269de1cf0c5910f3 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:51 +0900 Subject: [PATCH 440/527] feat(controller): add endpoint in PiaShopController to handle Anvil item usage --- .../demo/controller/PiaShopController.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 3759758d..8411b9f4 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -1,9 +1,12 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.items.ItemDTO; +import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.piashop.PiaItemRequest; import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.dto.piashop.PurchasePiaItemRequest; +import com.scriptopia.demo.service.ItemService; import com.scriptopia.demo.service.PiaShopService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -18,6 +21,7 @@ @RequestMapping("/shops") public class PiaShopController { private final PiaShopService piaShopService; + private final ItemService itemService; @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping("/items/pia") @@ -44,7 +48,7 @@ public ResponseEntity> getPiaItems() { } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/pia/item/purchase") + @PostMapping("/pia/purchase") public ResponseEntity purchasePiaItem( @RequestBody PurchasePiaItemRequest requestDto, Authentication authentication) { @@ -54,5 +58,16 @@ public ResponseEntity purchasePiaItem( return ResponseEntity.ok("PIA 아이템을 구매했습니다."); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @PostMapping("/pia/items/anvil") + public ResponseEntity useItemAnvil( + @RequestBody ItemDefRequest request, + Authentication authentication) { + + ItemDTO response = piaShopService.useItemAnvil(authentication.getName(), request); + + return ResponseEntity.ok(response); + } + } From 5911edc130ea309d90d6885444db9a19a6af61c1 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:52 +0900 Subject: [PATCH 441/527] feat(exception): define new error codes for Anvil item usage validation --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index feeb70c1..1d676a8c 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -42,7 +42,6 @@ public enum ErrorCode { E_400_INVALID_ENUM_TYPE("E400028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), E_400_TAG_DUPLICATED("E400029", "중복된 태그입니다.", HttpStatus.BAD_REQUEST), - //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), E_401_INVALID_CREDENTIALS("E401001","이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), From 7ec0774f5c8a05718d45a8996d64c813c47735d0 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:52 +0900 Subject: [PATCH 442/527] feat(repository): extend PiaItemRepository with methods to support Anvil item operations --- .../com/scriptopia/demo/repository/PiaItemRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java index 4e60fda4..f19585f4 100644 --- a/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/PiaItemRepository.java @@ -4,8 +4,12 @@ import com.scriptopia.demo.domain.PiaItem; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface PiaItemRepository extends JpaRepository { boolean existsByName(String name); // 이름으로 중복 체크 boolean existsByNameAndIdNot(String name, Long id); + + Optional findByName(String itemName); } From b0b3f7b1e770f56d8debf7bb4b0c6b4f145660d3 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:52 +0900 Subject: [PATCH 443/527] feat(service): integrate Anvil item usage logic into ItemService --- src/main/java/com/scriptopia/demo/service/ItemService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/ItemService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java index 83f54fff..be4d586f 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemService.java @@ -162,7 +162,9 @@ public String createItemInGame(ItemDefRequest request) { @Transactional public ItemDTO createItemInWeb(String userId, ItemDefRequest request) { - User user = userRepository.findById(Long.valueOf(userId)).get(); + User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); InitItemData initItemData = new InitItemData(itemGradeDefRepository, effectGradeDefRepository); From 4fc7e6635404de3061e33b43d6aa43397e2f5115 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:53 +0900 Subject: [PATCH 444/527] feat(service): implement Anvil item effect handling in PiaShopService --- .../demo/service/PiaShopService.java | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/PiaShopService.java b/src/main/java/com/scriptopia/demo/service/PiaShopService.java index d206e8dc..728e5f7d 100644 --- a/src/main/java/com/scriptopia/demo/service/PiaShopService.java +++ b/src/main/java/com/scriptopia/demo/service/PiaShopService.java @@ -4,18 +4,18 @@ import com.scriptopia.demo.domain.PiaItemPurchaseLog; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserPiaItem; +import com.scriptopia.demo.dto.items.ItemDTO; +import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.piashop.PiaItemRequest; import com.scriptopia.demo.dto.piashop.PiaItemResponse; import com.scriptopia.demo.dto.piashop.PiaItemUpdateRequest; import com.scriptopia.demo.dto.piashop.PurchasePiaItemRequest; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; -import com.scriptopia.demo.repository.PiaItemRepository; -import com.scriptopia.demo.repository.PurchaseLogRepository; -import com.scriptopia.demo.repository.UserPiaItemRepository; -import com.scriptopia.demo.repository.UserRepository; +import com.scriptopia.demo.repository.*; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +31,8 @@ public class PiaShopService { private final UserRepository userRepository; private final UserPiaItemRepository userPiaItemRepository; private final PurchaseLogRepository purchaseLogRepository; + private final ItemService itemService; + private final UserItemRepository userItemRepository; @Transactional public String createPiaItem(PiaItemRequest request) { @@ -149,5 +151,40 @@ public void purchasePiaItem(Long userId, PurchasePiaItemRequest request) { purchaseLogRepository.save(log); } + @Transactional + public ItemDTO useItemAnvil(String userId, ItemDefRequest request){ + + User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + String ItemName = "아이템 모루"; + PiaItem piaItem = piaItemRepository.findByName(ItemName).orElseThrow( + () -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND) + ); + + UserPiaItem userPiaItem = userPiaItemRepository.findByUserAndPiaItem(user, piaItem).orElseThrow( + () -> new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED) + ); + + Long quantity = userPiaItem.getQuantity(); + if (quantity < 1){ + throw new CustomException(ErrorCode.E_400_ITEM_NOT_OWNED); + } + + ItemDTO itemInWeb = itemService.createItemInWeb(userId, request); + quantity-=1; + if (quantity == 0){ + userPiaItemRepository.delete(userPiaItem); + } + else { + userPiaItem.setQuantity(quantity); + } + return itemInWeb; + + + + + } + } From 05b08421a1d1debfb0b12da16601528867ce8921 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:45:53 +0900 Subject: [PATCH 445/527] feat(config): introduce PiaShopInitializer to configure initial setup for Anvil item feature --- .../demo/config/PiaShopInitializer.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java diff --git a/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java b/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java new file mode 100644 index 00000000..10f908ee --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java @@ -0,0 +1,41 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.PiaItem; +import com.scriptopia.demo.repository.PiaItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PiaShopInitializer implements ApplicationRunner { + + private final PiaItemRepository piaItemRepository; + + @Override + public void run(ApplicationArguments args) { + + if (!(piaItemRepository.existsByName("아이템 모루"))) { + PiaItem piaItem = initializePiaItem( + "아이템 모루", + 300L, + "이세계 포털에서 랜덤한 아이템을 한 개 꺼내온다. 무엇이 들어있을 지는 아무도 모른다.." + ); + + piaItemRepository.save(piaItem); + } + } + + + + + + private PiaItem initializePiaItem(String name, Long price, String desc) { + PiaItem piaItem = new PiaItem(); + piaItem.setName(name); + piaItem.setPrice(price); + piaItem.setDescription(desc); + return piaItem; + } +} From f1045bc49884bbe4c0bb6ff8e2308656cb6cc367 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:47:01 +0900 Subject: [PATCH 446/527] feat: fix type --- .../java/com/scriptopia/demo/config/PiaShopInitializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java b/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java index 10f908ee..abc7148e 100644 --- a/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java +++ b/src/main/java/com/scriptopia/demo/config/PiaShopInitializer.java @@ -20,7 +20,7 @@ public void run(ApplicationArguments args) { PiaItem piaItem = initializePiaItem( "아이템 모루", 300L, - "이세계 포털에서 랜덤한 아이템을 한 개 꺼내온다. 무엇이 들어있을 지는 아무도 모른다.." + "이세계 포털에서 랜덤한 아이템을 한 개 꺼내온다. 무엇이 들어있을 지는 아무도 모른다..." ); piaItemRepository.save(piaItem); From c108a6a62caec85957385707647108b0b1bb13ba Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 18:48:49 +0900 Subject: [PATCH 447/527] refactor: add Pathvariable gameId feat: create equipItem --- .../controller/GameSessionController.java | 20 ++++- .../demo/service/GameSessionService.java | 82 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 98f37043..1569d4b3 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -71,8 +71,9 @@ public ResponseEntity getInGameData( @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/progress") + @PostMapping("/{gameId}/progress") public ResponseEntity keepGame( + @PathVariable("gameId") String gameId, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); @@ -83,8 +84,9 @@ public ResponseEntity keepGame( @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/test") + @PostMapping("/{gameId}/select") public ResponseEntity selectChoice( + @PathVariable("gameId") String gameId, @RequestBody GameChoiceRequest request, Authentication authentication) throws JsonProcessingException { @@ -95,6 +97,20 @@ public ResponseEntity selectChoice( return ResponseEntity.ok(response); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @PostMapping("/equipItem/{gameId}/{itemId}") + public ResponseEntity equipItem( + @PathVariable("gameId") String gameId, + @PathVariable("itemId") String itemId, + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + GameSessionMongo response = gameSessionService.gameEquipItem(userId, itemId); + + return ResponseEntity.ok(response); + } + /* diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index c1273bb1..0b87aa7f 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -834,6 +834,88 @@ public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) } + + + @Transactional + public GameSessionMongo gameEquipItem(Long userId, String itemId) { + // 1. 게임 세션 조회 + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); + List inventory = gameSessionMongo.getInventory(); + + // 2. 장착하려는 아이템 가져오기 (기존 ID 유지) + InventoryMongo targetInventory = inventory.stream() + .filter(inv -> inv.getItemDefId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + ItemDefMongo targetDef = itemDefMongoRepository.findById(itemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + ItemType category = targetDef.getCategory(); + + // 3. Toggle: 이미 장착되어 있으면 해제 + if (targetInventory.isEquipped()) { + targetInventory.setEquipped(false); + removeStats(playerInfo, targetDef); + return gameSessionMongoRepository.save(gameSessionMongo); + } + + // 4. 같은 카테고리 장착 아이템 찾기 + InventoryMongo currentlyEquipped = inventory.stream() + .filter(InventoryMongo::isEquipped) + .filter(inv -> { + ItemDefMongo def = itemDefMongoRepository.findById(inv.getItemDefId()).orElse(null); + return def != null && def.getCategory() == category; + }) + .findFirst() + .orElse(null); + + // 5. 기존 장착 해제 및 스탯 제거 + if (currentlyEquipped != null) { + ItemDefMongo oldDef = itemDefMongoRepository.findById(currentlyEquipped.getItemDefId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + currentlyEquipped.setEquipped(false); + removeStats(playerInfo, oldDef); + } + + // 6. 새 아이템 장착 및 스탯 적용 + targetInventory.setEquipped(true); + addStats(playerInfo, targetDef); + + // 7. 저장 (ID 유지) + return gameSessionMongoRepository.save(gameSessionMongo); + } + + // 스탯 더하기 + private void addStats(PlayerInfoMongo player, ItemDefMongo item) { + player.setStrength(player.getStrength() + safeStat(item.getStrength())); + player.setAgility(player.getAgility() + safeStat(item.getAgility())); + player.setIntelligence(player.getIntelligence() + safeStat(item.getIntelligence())); + player.setLuck(player.getLuck() + safeStat(item.getLuck())); + } + + // 스탯 빼기 + private void removeStats(PlayerInfoMongo player, ItemDefMongo item) { + player.setStrength(player.getStrength() - safeStat(item.getStrength())); + player.setAgility(player.getAgility() - safeStat(item.getAgility())); + player.setIntelligence(player.getIntelligence() - safeStat(item.getIntelligence())); + player.setLuck(player.getLuck() - safeStat(item.getLuck())); + } + + // null-safe 처리 + private int safeStat(Integer stat) { + return stat != null ? stat : 0; + } + + + + /** * battle에서 사용 * item -> request로 쉽게 매필 From 75ff9927bc1e6211a9650ff08b15570cdf609309 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 19:42:52 +0900 Subject: [PATCH 448/527] UserCharacterImg servie, controller edit --- .../com/scriptopia/demo/config/WebConfig.java | 21 ++++ .../UserCharacterImgController.java | 32 ++++-- .../UserCharacterImgResponse.java | 9 ++ .../scriptopia/demo/exception/ErrorCode.java | 1 + .../UserCharacterImgRepository.java | 7 ++ .../demo/service/UserCharacterImgService.java | 103 +++++++++++++++--- src/main/resources/application.yml | 5 +- .../93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg | Bin 0 -> 382330 bytes 8 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/config/WebConfig.java create mode 100644 src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java create mode 100644 uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg diff --git a/src/main/java/com/scriptopia/demo/config/WebConfig.java b/src/main/java/com/scriptopia/demo/config/WebConfig.java new file mode 100644 index 00000000..fb9a1d5b --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Value("${image-dir}") + private String imageDir; + + @Value("${image-url-prefix:/images}") + private String imageUrlPrefix; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler(imageUrlPrefix + "/**") + .addResourceLocations("file:" + imageDir); + } +} diff --git a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java index 824d1c37..b1243165 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java @@ -3,23 +3,41 @@ import com.scriptopia.demo.service.UserCharacterImgService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @RestController -@RequestMapping("/user/img") +@RequestMapping("/user/me") @RequiredArgsConstructor public class UserCharacterImgController { private final UserCharacterImgService userCharacterImgService; - @PostMapping("/save") - public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { + /* + 등록할 수 있는 이미지 저장 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/save/img") + public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { Long userId = Long.valueOf(authentication.getName()); return userCharacterImgService.saveCharacterImg(userId, file); } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/profile-images/url") + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.saveUserCharacterImg(userId, url); + } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @GetMapping("/images") + public ResponseEntity getUserCharacterImgs(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.getUserCharacterImg(userId); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java b/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java new file mode 100644 index 00000000..e361f732 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java @@ -0,0 +1,9 @@ +package com.scriptopia.demo.dto.usercharacterimg; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +public class UserCharacterImgResponse { + private String imgUrl; +} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index feeb70c1..d60d1a60 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -41,6 +41,7 @@ public enum ErrorCode { E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_ENUM_TYPE("E400028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), E_400_TAG_DUPLICATED("E400029", "중복된 태그입니다.", HttpStatus.BAD_REQUEST), + E_400_IMAGE_URL_ERROR("E40030", "요청값이 잘못되었습니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized diff --git a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java index d618a697..68d357e1 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java @@ -4,5 +4,12 @@ import com.scriptopia.demo.domain.UserCharacterImg; import org.springframework.data.jpa.repository.JpaRepository; +import javax.swing.text.html.Option; +import java.util.List; +import java.util.Optional; + public interface UserCharacterImgRepository extends JpaRepository { + Optional findByUserIdAndImgUrl(Long userId, String imgUrl); + boolean existsByUserIdAndImgUrl(Long userId, String imgUrl); + List findAllByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java index 4d90bec2..3bc09286 100644 --- a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java +++ b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java @@ -2,17 +2,23 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserCharacterImg; +import com.scriptopia.demo.dto.usercharacterimg.UserCharacterImgResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.UserCharacterImgRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @Service @@ -21,37 +27,100 @@ public class UserCharacterImgService { private final UserCharacterImgRepository userCharacterImgRepository; private final UserRepository userRepository; + @Value("${image-dir}") + private String imageDir; + + @Value("${image-url-prefix:/images}") + private String imageUrlPrefix; + @Transactional public ResponseEntity saveCharacterImg(Long userId, MultipartFile file) { - if(file.isEmpty()) { + String url = storeUserImage(userId, file); + return ResponseEntity.ok(url); + } + + @Transactional + public ResponseEntity saveUserCharacterImg(Long userId, String imageUrl) { + if(imageUrl == null || imageUrl.isEmpty()) { + throw new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + UserCharacterImg src = userCharacterImgRepository.findByUserIdAndImgUrl(userId, imageUrl) + .orElseThrow(() -> new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR)); + + user.setProfileImgUrl(src.getImgUrl()); + userRepository.save(user); + + return ResponseEntity.ok(user.getProfileImgUrl()); + } + + public ResponseEntity getUserCharacterImg(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + List img = userCharacterImgRepository.findAllByUserId(user.getId()); + + List dto = new ArrayList<>(); + for(UserCharacterImg imgItem : img) { + UserCharacterImgResponse imgdto = new UserCharacterImgResponse(); + imgdto.setImgUrl(imgItem.getImgUrl()); + dto.add(imgdto); + } + + return ResponseEntity.ok(dto); + } + + private String storeUserImage(Long userId, MultipartFile file) { + if (file == null || file.isEmpty()) { throw new CustomException(ErrorCode.E_400_EMPTY_FILE); } + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new CustomException(ErrorCode.E_400_EMPTY_FILE); // 이미지 외 업로드 차단 + } + User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - try { - String tmpDir = System.getProperty("java.io.tmpdir"); + // 파일명/경로 생성 + String ext = getExtension(file.getOriginalFilename(), contentType); + String saveName = UUID.randomUUID() + ext; - String originalFilename = file.getOriginalFilename(); - String ext = ""; - if (originalFilename != null && originalFilename.contains(".")) { - ext = originalFilename.substring(originalFilename.lastIndexOf(".")); - } - String saveName = UUID.randomUUID() + ext; + // 사용자별 하위 폴더(예: {imageDir}/character/{userId}/) + Path dir = Paths.get(imageDir, "character", String.valueOf(userId)) + .toAbsolutePath().normalize(); + Path dest = dir.resolve(saveName); - File savefile = new File(tmpDir, saveName); - file.transferTo(savefile); + try { + Files.createDirectories(dir); // 디렉터리 없으면 생성 + file.transferTo(dest.toFile()); // 파일 저장 - UserCharacterImg userCharacterImg = new UserCharacterImg(); - userCharacterImg.setUser(user); - userCharacterImg.setImgUrl(savefile.getAbsolutePath()); + // 정적 매핑 기준 공개 URL 생성: /images/character/{userId}/{uuid}.png + String publicUrl = String.format("%s/character/%d/%s", imageUrlPrefix, userId, saveName); - userCharacterImgRepository.save(userCharacterImg); + // DB에는 공개 URL 저장(프론트가 그대로 로 사용) + UserCharacterImg entity = new UserCharacterImg(); + entity.setUser(user); + entity.setImgUrl(publicUrl); + userCharacterImgRepository.save(entity); - return ResponseEntity.ok(userCharacterImg.getImgUrl()); + return publicUrl; } catch (Exception e) { throw new CustomException(ErrorCode.E_500_File_SAVED_FAILED); } } + + private String getExtension(String originalFilename, String contentType) { + // 1) 파일명에서 확장자 우선 + if (originalFilename != null && originalFilename.contains(".")) { + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } + // 2) 없으면 MIME으로 대체 + String subtype = contentType.substring(contentType.indexOf('/') + 1); // e.g. image/png → png + return "." + subtype; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eb8188ff..7e14b2fe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,4 +69,7 @@ auth: app: admin: username: ${ADMIN_NAME} - password: ${ADMIN_PASSWORD} \ No newline at end of file + password: ${ADMIN_PASSWORD} + +image-dir: ./uploads/ +image-url-prefix: /images \ No newline at end of file diff --git a/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg b/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..98795f0cfa6b6a17cda46263e0c280069ddc94d7 GIT binary patch literal 382330 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¥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

6zqsCs==$eSD>3Y;?3oVt_?MFZFqjY4R}I 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%0D81U5lYtAI`t6hx%W(8GhLAnup-cxGDF(jv8c=^u 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ÐW)D1WU+?_Xe?y7+_@vPNQZ^DEjR)u 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 Date: Sat, 13 Sep 2025 19:45:09 +0900 Subject: [PATCH 449/527] SharedGameController edit --- .../demo/controller/SharedGameController.java | 17 +++++++++++ .../demo/controller/TagDefController.java | 29 ------------------- 2 files changed, 17 insertions(+), 29 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/controller/TagDefController.java diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index efdc9709..97ac9eaf 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -2,11 +2,14 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; +import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +25,7 @@ public class SharedGameController { private final SharedGameService sharedGameService; private final SharedGameFavoriteService sharedGameFavoriteService; + private final TagDefService tagDefService; /* 게임 공유 -> 게임 공유하기 @@ -97,4 +101,17 @@ public ResponseEntity getMySharedGames(Authentication authentication) { return sharedGameService.getMySharedGames(userId); } + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @PostMapping("/tags") + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { + + return tagDefService.addTagName(req); + } + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @DeleteMapping("/tags") + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { + return tagDefService.removeTagName(req); + } } diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java deleted file mode 100644 index ae830c69..00000000 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; -import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; -import com.scriptopia.demo.service.TagDefService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -@RestController("/shared-games/tags") -@RequiredArgsConstructor -public class TagDefController { - private final TagDefService tagDefService; - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @PostMapping - public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { - - return tagDefService.addTagName(req); - } - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @DeleteMapping - public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { - return tagDefService.removeTagName(req); - } -} From 75d28d0663535cab842d70743582c173d8c75a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=84=B1=EC=9C=A4?= <86961575+KII1ua@users.noreply.github.com> Date: Sat, 13 Sep 2025 19:49:17 +0900 Subject: [PATCH 450/527] =?UTF-8?q?Revert=20"[Feature]=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/scriptopia/demo/config/WebConfig.java | 21 ---- .../demo/controller/SharedGameController.java | 17 --- .../demo/controller/TagDefController.java | 29 +++++ .../UserCharacterImgController.java | 32 ++---- .../UserCharacterImgResponse.java | 9 -- .../scriptopia/demo/exception/ErrorCode.java | 1 - .../UserCharacterImgRepository.java | 7 -- .../demo/service/UserCharacterImgService.java | 103 +++--------------- src/main/resources/application.yml | 5 +- .../93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg | Bin 382330 -> 0 bytes 10 files changed, 54 insertions(+), 170 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/config/WebConfig.java create mode 100644 src/main/java/com/scriptopia/demo/controller/TagDefController.java delete mode 100644 src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java delete mode 100644 uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg diff --git a/src/main/java/com/scriptopia/demo/config/WebConfig.java b/src/main/java/com/scriptopia/demo/config/WebConfig.java deleted file mode 100644 index fb9a1d5b..00000000 --- a/src/main/java/com/scriptopia/demo/config/WebConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.scriptopia.demo.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - @Value("${image-dir}") - private String imageDir; - - @Value("${image-url-prefix:/images}") - private String imageUrlPrefix; - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler(imageUrlPrefix + "/**") - .addResourceLocations("file:" + imageDir); - } -} diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 97ac9eaf..efdc9709 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -2,14 +2,11 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; -import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; -import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; -import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -25,7 +22,6 @@ public class SharedGameController { private final SharedGameService sharedGameService; private final SharedGameFavoriteService sharedGameFavoriteService; - private final TagDefService tagDefService; /* 게임 공유 -> 게임 공유하기 @@ -101,17 +97,4 @@ public ResponseEntity getMySharedGames(Authentication authentication) { return sharedGameService.getMySharedGames(userId); } - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @PostMapping("/tags") - public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { - - return tagDefService.addTagName(req); - } - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @DeleteMapping("/tags") - public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { - return tagDefService.removeTagName(req); - } } diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java new file mode 100644 index 00000000..ae830c69 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/controller/TagDefController.java @@ -0,0 +1,29 @@ +package com.scriptopia.demo.controller; + +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; +import com.scriptopia.demo.service.TagDefService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController("/shared-games/tags") +@RequiredArgsConstructor +public class TagDefController { + private final TagDefService tagDefService; + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @PostMapping + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { + + return tagDefService.addTagName(req); + } + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @DeleteMapping + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { + return tagDefService.removeTagName(req); + } +} diff --git a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java index b1243165..824d1c37 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java @@ -3,41 +3,23 @@ import com.scriptopia.demo.service.UserCharacterImgService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @RestController -@RequestMapping("/user/me") +@RequestMapping("/user/img") @RequiredArgsConstructor public class UserCharacterImgController { private final UserCharacterImgService userCharacterImgService; - /* - 등록할 수 있는 이미지 저장 - */ - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/save/img") - public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { + @PostMapping("/save") + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { Long userId = Long.valueOf(authentication.getName()); return userCharacterImgService.saveCharacterImg(userId, file); } - - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/profile-images/url") - public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.saveUserCharacterImg(userId, url); - } - - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @GetMapping("/images") - public ResponseEntity getUserCharacterImgs(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.getUserCharacterImg(userId); - } } diff --git a/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java b/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java deleted file mode 100644 index e361f732..00000000 --- a/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.scriptopia.demo.dto.usercharacterimg; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -public class UserCharacterImgResponse { - private String imgUrl; -} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index f229996e..1d676a8c 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -41,7 +41,6 @@ public enum ErrorCode { E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_ENUM_TYPE("E400028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), E_400_TAG_DUPLICATED("E400029", "중복된 태그입니다.", HttpStatus.BAD_REQUEST), - E_400_IMAGE_URL_ERROR("E40030", "요청값이 잘못되었습니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), diff --git a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java index 68d357e1..d618a697 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java @@ -4,12 +4,5 @@ import com.scriptopia.demo.domain.UserCharacterImg; import org.springframework.data.jpa.repository.JpaRepository; -import javax.swing.text.html.Option; -import java.util.List; -import java.util.Optional; - public interface UserCharacterImgRepository extends JpaRepository { - Optional findByUserIdAndImgUrl(Long userId, String imgUrl); - boolean existsByUserIdAndImgUrl(Long userId, String imgUrl); - List findAllByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java index 3bc09286..4d90bec2 100644 --- a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java +++ b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java @@ -2,23 +2,17 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserCharacterImg; -import com.scriptopia.demo.dto.usercharacterimg.UserCharacterImgResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.UserCharacterImgRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; +import java.io.File; import java.util.UUID; @Service @@ -27,100 +21,37 @@ public class UserCharacterImgService { private final UserCharacterImgRepository userCharacterImgRepository; private final UserRepository userRepository; - @Value("${image-dir}") - private String imageDir; - - @Value("${image-url-prefix:/images}") - private String imageUrlPrefix; - @Transactional public ResponseEntity saveCharacterImg(Long userId, MultipartFile file) { - String url = storeUserImage(userId, file); - return ResponseEntity.ok(url); - } - - @Transactional - public ResponseEntity saveUserCharacterImg(Long userId, String imageUrl) { - if(imageUrl == null || imageUrl.isEmpty()) { - throw new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR); - } - - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - UserCharacterImg src = userCharacterImgRepository.findByUserIdAndImgUrl(userId, imageUrl) - .orElseThrow(() -> new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR)); - - user.setProfileImgUrl(src.getImgUrl()); - userRepository.save(user); - - return ResponseEntity.ok(user.getProfileImgUrl()); - } - - public ResponseEntity getUserCharacterImg(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - List img = userCharacterImgRepository.findAllByUserId(user.getId()); - - List dto = new ArrayList<>(); - for(UserCharacterImg imgItem : img) { - UserCharacterImgResponse imgdto = new UserCharacterImgResponse(); - imgdto.setImgUrl(imgItem.getImgUrl()); - dto.add(imgdto); - } - - return ResponseEntity.ok(dto); - } - - private String storeUserImage(Long userId, MultipartFile file) { - if (file == null || file.isEmpty()) { + if(file.isEmpty()) { throw new CustomException(ErrorCode.E_400_EMPTY_FILE); } - String contentType = file.getContentType(); - if (contentType == null || !contentType.startsWith("image/")) { - throw new CustomException(ErrorCode.E_400_EMPTY_FILE); // 이미지 외 업로드 차단 - } - User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - // 파일명/경로 생성 - String ext = getExtension(file.getOriginalFilename(), contentType); - String saveName = UUID.randomUUID() + ext; + try { + String tmpDir = System.getProperty("java.io.tmpdir"); - // 사용자별 하위 폴더(예: {imageDir}/character/{userId}/) - Path dir = Paths.get(imageDir, "character", String.valueOf(userId)) - .toAbsolutePath().normalize(); - Path dest = dir.resolve(saveName); + String originalFilename = file.getOriginalFilename(); + String ext = ""; + if (originalFilename != null && originalFilename.contains(".")) { + ext = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String saveName = UUID.randomUUID() + ext; - try { - Files.createDirectories(dir); // 디렉터리 없으면 생성 - file.transferTo(dest.toFile()); // 파일 저장 + File savefile = new File(tmpDir, saveName); + file.transferTo(savefile); - // 정적 매핑 기준 공개 URL 생성: /images/character/{userId}/{uuid}.png - String publicUrl = String.format("%s/character/%d/%s", imageUrlPrefix, userId, saveName); + UserCharacterImg userCharacterImg = new UserCharacterImg(); + userCharacterImg.setUser(user); + userCharacterImg.setImgUrl(savefile.getAbsolutePath()); - // DB에는 공개 URL 저장(프론트가 그대로 로 사용) - UserCharacterImg entity = new UserCharacterImg(); - entity.setUser(user); - entity.setImgUrl(publicUrl); - userCharacterImgRepository.save(entity); + userCharacterImgRepository.save(userCharacterImg); - return publicUrl; + return ResponseEntity.ok(userCharacterImg.getImgUrl()); } catch (Exception e) { throw new CustomException(ErrorCode.E_500_File_SAVED_FAILED); } } - - private String getExtension(String originalFilename, String contentType) { - // 1) 파일명에서 확장자 우선 - if (originalFilename != null && originalFilename.contains(".")) { - return originalFilename.substring(originalFilename.lastIndexOf(".")); - } - // 2) 없으면 MIME으로 대체 - String subtype = contentType.substring(contentType.indexOf('/') + 1); // e.g. image/png → png - return "." + subtype; - } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7e14b2fe..eb8188ff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,7 +69,4 @@ auth: app: admin: username: ${ADMIN_NAME} - password: ${ADMIN_PASSWORD} - -image-dir: ./uploads/ -image-url-prefix: /images \ No newline at end of file + password: ${ADMIN_PASSWORD} \ No newline at end of file diff --git a/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg b/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg deleted file mode 100644 index 98795f0cfa6b6a17cda46263e0c280069ddc94d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 382330 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¥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

6zqsCs==$eSD>3Y;?3oVt_?MFZFqjY4R}I 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%0D81U5lYtAI`t6hx%W(8GhLAnup-cxGDF(jv8c=^u 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ÐW)D1WU+?_Xe?y7+_@vPNQZ^DEjR)u zZpvQaLhS_L3#?x&8%XtpA>KdhXoyEz)w^pz)pG|w%{%&EBO@pJwxRMTBkmP`$#tpq z5)=}x;73}=`yo|AYz=HH{(%zLkdHaW?q+aee+2R8+KAY&BoE2wzYvqsKLPqWa(jd& z2xV3Pc62d0h@qU+$`0&rKbMS__02=2cae=XIE9+3e&%K<>u$Xk$LRW8q#*vak?5HE z%R*(1K$y5*FXo4->R!b0L`L@jF_vklN?-x%g=*9q6y9(!`O=d5KXaT0Y!j#Nj>j8i zS*9ho`#i&mLEqz^C`K8+(H})4yW0}zl{Fn!WOUB%_s_UM3*GFyVP_%38=sI`BsVa{D+O2?aKayKw!{G?7*|gw6l}du^Qn%83DJ)E2bG8lqMJ)(Q^1jx_T&od z(CY%kO0V*b%2%Yu{}%3-)P?J)&;P8tv4`~9xlYX_Nd^jaRB@xs+eE`VYbkZwJsbYF zc+y%mN8jXW3MJ;yIZjtw=dMIQc|$G4gG3)CNz4!XBu`OyyQVw*6ok5KTGP(`Qq$a8 zE==yzCFKVw`x}Bm)`2V z#lyq_>N|m=>p9oO`O>Qyy07?HkZX)Q&`J5Ne;I$YMX2G=4fi}^Jb^@>xmPXi6->FuKEA@-2K}A=aJ~e`-tXQR}R}1 zXO^>Rg~pb)w4CbXV`&kvT7<$C*Fjh8koK;MIXVrkgD z{{bh=#JgHbCOj#vIr`a{8TEiUrBuH6+(JCWH>KZ>dx+4e9MHZ<%t#_s3==35y^>aF zz0AviyhNKZ)De@z`#5{8y4=c*x08-cMt*F1B-Nl|i8Q8RQOYvFyXX}%%5infPmD>U zSym{sjHzf$AuG*0-|-aGR1ws2&FoLvCv$ZlN&Tj7+QE5+;8b*vdy9rAFA*sbo|(Jw zC6yW46L8&&wM*KmQtRO6Q@5|JSuPYdtdWXjS5}4G#s1~=-<2y7v$&X+@0!&w55qjl z^+jgJWf>Ao8Z!_C6K@m9=T5uXKQF~CO8GW!)3>9rXveclH`&f@RRhS`6sfaR(c2eu z1XYmKewbm;j9ChVSu;QL@dp*25MVN}EJX#i!l@sx8(5VzMU7pRt@vT~wc;N-!-Sdw zzH`HKlBjA&xig7JH?r6a+_V0@Qb(wt;U8cZdG`q`ttIiwsJ-*RNy&@qxZ?0EXu{Fo zTY4TD{V9TeGk*GQN6mmy;6ax3Yxa)<8LL~3W$H}2CDF^D>hf4A3%=O*s{a>BvFx1s z-+SxP&Y$Ps{#QMZ&ad&Vw@AK<&)N@d9kP~ZLfX1r^u%1%4fP~JwBf7-ApR?RmK$wQ zcv1rv`nQ4AW@~QvXtKysy|^@eP3)346n2;&hU=@gtnA3enL>hnhB=lM7fvZZ$t$n} zW)+X9?qO2xNTT#)=2rz`i!+qL)oQCA)_-FfT??LDfWV69RTA^BoCMcPfaSePgdp)&BNEc% zK3iv?T_Nrw9)5J?uQjEqH)7r6azrDc_EEJpzeZxbSi4VX3)Ta)m>uPsQ!;`S7?!aHGU^xJhkN(^ z1SuhKO#{MrWYPWMJYxqg6}JPeR>GcMxDL0LGX2e=m<$}97hsK-zW}^8T=G!dW`?`G zROnY`6GZ6Qu7;Ptz=HkT z(kYRfLVi+aBfi_TPE$ui`%C_qGyNat@~a_h$IN>;Lb^ z@GpO-QV_LML3)h&HF8IEL6#A9+9|a(itkBB%meJ+Hl?6BBnj_J#G^0{*X`hwC0=66-3sPf~ka9qJRD@(FNE2Bh5v2$B2!*WMJg}Tk}Slz5X?_yOPd~(uH z$@lI9cZQxg)BtC0 za+ZMysBAp{c z&D+pN*L(rAVbi zFikCsviv&@3T?Wz`XUf~;7ky7`KZZ-8t6LWL z+u8}2C4I+5{Iv-affPb$U~*}srwyOGFjH)~(3n?BsF&%wqlvH9WOeBQ;*!UDK-_wMK&_aKsR=3*Mdm>Kb=BAPm_V>H$5>f+ z@^7Wx^t{jdDgr9gw*Wv1C0bg6sp-Q<*eyDn{01ILGc`XkPlv?t_z)4nS4RGHb$zed zH8dUi*VfHUMq(LpI7RufS(<0e`9P+L{yqd zVJlPGv~zH<{`N%OKW@=>9#`KoijS2&Do6JAxTO;ZK`|G8$^t(aE%M6Q9tkuqFu7D_ z8W2jOV4U7ID~_jARCbgvwoYZl1kVuwQ&N;1Mp`kwNG7|Ahg%_j#z*Q4T37j@`JKLS zMCuZ?H`n7yE%(S@+!vsyn@L_TAX748qTGE2c=~q8H`61Cr zB-A^(-?lVGX58+_LYsnnq{h4N#a zSaY2ze?HtT!aVDG4dWm2iVGe(vA#Y$%Su39aLs>W zAG|kk`Y*2v$bbm0*c;0(M$;n3cDRa%iv+2Xgtki>NF%`;r(YRX)g#@?8*xn+0&9>` zRB4s)1*Q?xOIPoBWC?zHO74|!)YGahQ{wEA0Ih6(7ct$czYZ`b$&Eo{aDTPldyVRD zOfhQLTtt>8SBT2gn=YEwjMS0o7*)3eO3DsD<|bwuS{1n(maXqHCOBs!g0vU7KD&&I z-GU+fA9g25Jek`j$vkM1;MevHjbe@q7!xUlY$uA}X}xq!`H{Ch#H2LhGUmf9h-^|f z6*qyU*0&_Al@oLC zQ>T`OrcNGUs|u6kUSlQI5gYt4KYwWjdvK*;>${FlWvI2?qG`w-HEErf^}cQ&Tbd=L z;NA~Uq)qTdI?i}V@ikbqRY~dMWd1=|pl2m1p5D|hxZpR|w}@^|4y;AqFZAy-9?bUS z@`ymG56yWy-rEqPftk*2i@mODvfdk_4Tl*=QfWIoN7{3Fsn$5i$ajEZ+fve4qmcvk zk@|8m9aqX+K;x_sl^vM#?-g$8M_O%T8$t&3;jzY^2)U6`agm6FiqZ!a zX_bj%%py{;JtL#Z;_fEs+ju{zNBYt;XJ^4(|BLSTVfy)p<(dWJg0s*VjN@!-m5Q6slkoqx3URVTtMLcE)DNBHNhAp~JHwNcz&|_+bV0%p;o$x`zpW zIjc?p0Ky00#`WY?-wskEkbNmfe8IE;ib*jC2pQkMFLDN^)<8hcN>jc1Mh>|J+}5eB zt)s2T>y^5lo&J{e#`R1+eHq0PWqsK?QS?$%#vPXcwYC%}1J>-xrTq>aK&V!ud(?JC zzi&G1l|RY+c{icOSG#U;SU(zGqK$vR2`R{)Wff<3hR*AC?2MRO2-Q?>vb&`DSvE8p zCP{eiE*Fg&Q6F$J{{y1f;+WU%*ep|KSrpcP=yocF2KXwxeb`=_B=Hiu3H4Rb*G#RI zZ=LUbnKPt;p5Bm^0N?n*rzJA0GK*Pf*xkQXkn3oHMZyCSV4cmcY$s)NSgzwcpllfQ z%?wViVG3{R{aQ?Dm|o&yw{5Yi%415qGKe3WxF<9ngQ=Sa7nj?f%(w>#j(yr-kiHia zmuJ)`8u_}9OK>mYzHn7G3rl!l^!`^5{nP$3$=pey@VfLYG`IN`@}i!WwLaZQKzSmn z%2+)pcQ=f%jfRrMbJ8Q=X^~j-kAy50>6H3`Rq6g^m&#$3VTqFwz!<2vQDopBz5V@0 zl}5$hjn$Y?W|abScSnHP5MVtiZnQ+H{8}P&2iudLa?rMLp(r94936e59)0c`@C%e=XQ@k1jx zTW#q#kJ^R2gGClC=ZY@C;%@RAO;=|scL>TM^=9L~|6b`c{6!(@{4HH%_Z2i2nr?KY zE+kE2*z`*blEN@c5GXkgZ%TZ?@hvtyY;ojR=$5^c(;!TD>FG}J z7Sfkn(|kdGcux8u)hLm?(Sp(KUmst!0nh%(AqJ&$?S?&e#51no$;}m{4MK0Nul#5i zJ}}|$z^}u>^ zt&iaFifG8gvMTF)bN}QQ#|o8WgZo7_svL4i+q!N9W^@ag-s_09DwEIDcjH1CHO#ZX zUj`4Z3^LrCm<{e;as58v2tGBes6H2~trnsmaDds_5WMUVTW@Vs9+qUzWPWLB+F)zz zy*{DVRjxwX{WiI8BYO)b3QD}kQ>;(<3O%$sq)S)UddWR_z1s84s_iejjHX^z_2$B- zB`QCeC^MUllDdZ!N>i;)z1fefJ=H0zbf8Akc$h!gly`(7+w%JQF3#OwQqCg)jkCEM ztI^rOazd*N2z?6kWOpC1AMTtfV7$py{`!!#-$g_N#qm9^jT9n|D=}!^fU8p>1-W z16!0WHY2?DEYQ{w{fN2Zv^u0;7VfkS+iW5jo2hl_)=U1fThZv)+&F!xST9q2qv~-@ zf5y;=!u#deO4R5%j;ta=xep+lA_qeYlJuQlHR$*bGU4L94_nod{XZG>n_fO0%Cu2b zY%foLykCaGA6Fw{RIMndJ;72I{zS+WAc52m6!#k8gT%$#2f3& z&wnAk)4MRZpWHt;x?++P017(sjsCJ*mxRRt`4PT#YfD)l@)z#kcl*wiXTjw5>Q#6^ zoPw_OYdp&KeF1@~w}Gn;I1S4)%g)Sq6eR4_5&n!7@ z-GL+}KBPmXidmlFireXof{rCejJeL)O*YC=hDPbmnmr(}nJ@~Ilix*8l{>uVR#ET7 z^6&%FkJtT>lK&0pa;I>Ugy{HW>aMPyc<7^H* zcwgeTvmRa73NM*h&KlEjNo=%NbGTDWnl>Q~?;w1MS zjm3a4_6YHX)Boo`TBKg|UrEgsdUus*6fiqaPb0B#n@C&Usvnl&I#VWRx^Ak@uOggO zoVk>b^_k}sgI|5Vdk%aeaapA#lci}{S}ZK+mqT8od1ioW!}yb)u_GF>?>FL!Gz?1q zQ3(#eQv!ruh_#hSe3z=5xvu}|vszK6mBi2z0#Np*R2JKDbq95Vz2YmOytQC9bLaD3 zVQ$9La?rEJDve=|R0GF=YZ=eIrtcag8)m4?{g@b9DEJV4`ZJzw3$wLBmtK@kUk z4kMv0d1M@-?*@fMj?dw&vSHWWK1;+t9A{t0z&iClO{W$MZ(<5u%W*1SY3|$>c5@qM zo(@lE-T{4#2hSZXwBANyC$jfOaj@ajsHZGXp1sDi z^ha)IvUy+1{E)Z6mp0d#cxyw(LKB2qRBfXwBYCCinN#ZVRI|1E;N&00Bs8fn6YD4b z`sA`->m6#epWI_FsH~Q^pCKdnM`#@g7SNs59mZ(3G?SWZAO?$54Asc{&4ItKlgsad z{sd{gg&f->naaa2t&4o+o4<-puRUFK0W2}!Yi(M_x`wSp_x840TmlZRo_MpcM4qpm zIDpRBehYht(G?g)s{oX5?+85;=$0YQh;}1KUUpumCuL&kq-ulW4@kpji%%sSf8FN{ z0vCLPx4&#$bIcFpjcX_o|LeN9-vP+vQrQMq=qAR9CMy)jgE+-l=&>XG^HQN$X|&rGc)$jkd;WL~|nbt+2EQa_MRf@Q$hnI|s_L~ze^cic@4%u|f>?CRI?VMCT#DOv=d9I*f%$sLY<^HHV(%`UZiDYRJqXObYR)!bV8OQ~AJeAecPP0M5_qiu>qF*ClV z!N%pu(xnjZe<{->daKe@N^DnG>m|d(!>sSv)Iy5NDpE@gt*NY%wiOq{{0de*?4d&} zJkV$+>#S_(={&->3|DbLdx%rF{~0AumbHqcU$~ooJd@bd#)NuhzcU_$azP9~xSZ-=xkz2CQ5cK_w>+U=&9tF(7kC6gb2 z8+@v=f?UvR$OAANo0L?7D~k*j68PmhBqo`Re0B?-GPeT*BCIoUeDCz^R!TWeCf3sV z3@fE3w-eU^Y-rdbbIF=DYZ+VGsSaWc)}}xE8u|JHh?pj`DU2N5IsPN~dR@X+MlGE_ zS>^>IxIxL8&B5EfA}CR=2-LUbszV_+hlHa=}imS$d<%MnJHT0P$4noRD zvrn(vfJZ83K%0DgJUc>;~rFD+R>R?{Q2D2VHCJ&Z|Ny+&z*K!ca^P_NhBbb}?ogIgT;75Q0VBf>;p+)K^{(78QgWZB0D1*kYM zZ07(=Cl3@{$NbonRcN+_crHDo!h_1n<5Dv(Hs+>Q_6l1JwjlLlFKPLGQGRQux2 z%Y%z!YP;w2IbLOW-`p%#h*Oyb$G5Rv8dJ<$#1X?~B&4R%SVE>b;fmd+%jsL`)^)RT z2(1``l+3CIvwCOEjlAD>y?JNy^?bAR)6DrnbDo24F zN7|PGPHc9D)>!oomtZE^rDS5VnEkNJ7x=$dfcM=7w1uO38?bJTVbW@h^ZXF* z`2F7>_IK!Vomehao|TEh8)M`xELh_bAU?VWzrjCn$5-Z&kbGpL z{GjF(?qa>4bcA>aRNQ#8-3U?|#UKX$JezOa4g6tjhaMyKV+s{I9GeJIAn91JLF#5A1$3SEnltwQ|@l^y0WY0Sl2>X`wvAyq<8*)YG7$4{1cwu+$97P+?P#K*)&Z; z_MAUb9MWS`2 z?i`fp?Pp4RJX*@DprZu3`fZLomHxkAz*5sRGe z!A8LP#02vO@h0~;nz#xQY*8;*fuI*UTTEa7`2W4#Ls;JGR%S@FG$c2vi@ZgO$H@cI z#f*YELDM*q#j%GM+X#dhy1MPVF#$V|n5AS?1iolmLjmxW3z1b2-=w)*`xBQ%!tNq}aVaP(jd32Soy@7#OIY-L%Z zniO(7#Ey~Eo+>Fpjm4YHs;Kv=h2OSec8QR4D^(spkjAR1x!nS;P2C>(Iv)jvSw-iF z1PB+1%O|?e)sTDD1a~E+c`$DUGAifZSwtFB#v(o>*}AEHz}Vp6X{>QX{oJE`@p<=n zv@)H^;9^|p;4{11=lxhy1}naqgJk$k9U)nUcw?)YC!tVG?n{r|_tS+EQ!!e5%+bz{ z!nP|sLltw!gzasT&YBoePZWukO3kH?WHJk&3v4thY}4xo(pA_}drpOAcBI#g_spL^ zhZx5p{N|Pkw-}iJ(St%T?{k<1_~X=jKkpu?mrG~VsSh$11qbUQxXqq@_#V>}Yw_*u z`f`F7=@^F()^O(Vd#2{6nT1ltNnJ^t`8oXX%kJZRG(ls3Uj05l{lg(ngp5$*;Ddq* zylq%KctO}S_0_m_BxNqYLAGt!m+td5Mi;t^=o9h33^M9OOk$z=5uM?$ESM@RKYC(@ z9U&&gGSYy3f%E$-o5?$aa;GQj<#sSAE$bmKtX_O`YfuS zgv4a_=OMBj?37f_!5{I^V{AT@d`XnAL|uZNT5jMp-A~sxmwXDV=MNi_^0)67j|KX^ zF*_1`2yZ1vm-q0t&%`%y>uz|92q<<_&Xj4RDk7yzzJ4?gW?W+Nu zLXkGMO>Na}tLw9T8MP}T|7}%ecPn>@U=kzA&vd4g}+rrXPW()v&Im!_1Ok) zQ_{U^k|Tm3AerQ0`-CSvjb7pFFg`g?0RTAtoAD1(o)Bu>1Tj6F1@wH&oF0n8APjm;!`zsL@RBX z^M(s7VjOc){?_UZ9^xwLqB7A23w>QzZn|Khv2a$abWcxi*JC{TL^_8}L?{0{H+`Ye z65SwUwQn@yc4OAhYZ1syiW#4}?5!Pevx<~e8b2EBKRPd2sXAy>7RE60V`7FadoM{@ z>U;wR183kv$ftkNTzW!K4!(eK@8pJefIA2&0(NQv+-x6Aqas4C@fI}Ih`hw4Sn23# zo5a>vzZ0m4(Yr%B+h&y4xAxssDeYPvujlu)#Wc(K%S8(}sm@i7^x*FpA#b4my+RE4 z!H6v6rF>;bl$-vx3F$L^Gd)^=t@;B^ubupvzjrtfMdc4r%H`=VXjlnFkzfu-5G*C9 zxJsRJ=jecD-MJL-lUHf$v7dEzr zHPH`_VD?D7;QS-LuI6QKSFO%4R0WG8BTy@#Em|l*JbBYb|BU$pFkf7wfnc0+SG7d- z%7#4q$-Q2#R>Mai--XCGvNudsOZiDp)on8Q z{5P#`fMx^dN+k>}ot65T+1x#HJMkjt_S|6!bkYGG{DZ{xo%BEdxEXa-zVOy(8ms3p z9e31}fsf|U$~d`V+?#(O*#X?HXb+ZJWCca>O|4I>Udm7U%8*7uWq!9t-&}b(R>J`325gf&MCnEERUr%BwpC?1b$ws~D!(XeOrW#~VQD;CdEJ5GolEDgh9u z;)SYiOSO9g5n{wW(xMdUQUFO>yd+3mvh6_`%O=ZTN`mI8mVzif2I*~-0jr4gDws+D9=WR}RZWZ;U#EulWgw4H@+31Lw zjQRU$dU3i{^dGJh?o6YVa%icO#VBlrJ4c|3(ZEtIm7tuG?3GV$R_F79q)u$wo2eA< zo=sZ1@&Kz&YnD>d%bA6AKi_Z&YaSRQ7trP3{Zo`4pb)jd81#g6T|2o|Y3O#s)5A4E zH(ibOz2?cebAhyuro!S@@niy(QPmE5`h1A+((q)K&21Jl!86Y{NKh}(`(7Rq$yktm zB2!@7xJV7?@*WDpJMEJ@QKv5!_|vd)Sn1c3@@5J_H+8@UOz`o-XPCr5tzr{EUwwxl zjd!8g9NTV#5M@Rk`og80_W;Vnt(I2*ZXpN2Zm5`13CGJS z0P0&^ZEl?hBBy}ibyL!$$98w(AlXvd_*nacx)GmvrJIR1`084%rMG+=jm9(PK9fH- za?X=N{_sHDJsgUkC~ofrpl8GpW|n08Yk;T~Wl3U*@o2liDb0w|&9{dbg3x)qZR_r@ zn{%8iOz=1hP9p`H{fG1WeQIkO68f&*N)lq{dNxG+ee2GESHo@!y*HSB47j$*OTx1M zUhx{YzIxOKNdi^eryVxisd^9!L}WEIcnhe5y}s*U8d|{SO+S;h=bBEgCXC9gy}ch2 zsMI#891NM!uBcl{`WCoou1*1?C268nvm?D+u+}Ta=iZ_Y!}M{N^%9THq4oGUyhu>v z?~9tXEGcDf2;Pl~~ z-`!4=E_=qFx)P`EPSz1Qp(#F~=Gi)v_11C=k4`PKWW)5&i90oWx5lke={avCe(R_E zplA)ya%?(}ctuWn%(#vtlP<)QvbQ-T>JBG;~F{p*635q7Z7&YEb$A@8@8V5OeV7H+*ayqS$pvpu z!lzMRp9*^0J^Psz?ME8bLkq9a!@{F!r`3M(L-k!h64f^j#eLz~HCk>$XwBP?SF(>r zwKjUA$EJM&mK}q=lY034eTMN<;)yP2mR;}QH`AXE9uDOj09C)i)Y0dnE&F934f|2T{Ke5E8zBzf8(_Brj190 z$63YnTetF}zWP?TH4sGpDH0UeFHw)KvGTK)Lr;M>q$3QM{aiUGMB27K5;~B7-kYFX zzLf*f+X9ay13mp7zxkT@@-j;nbY>9{nL0p)y9MWRGpK)fzvySxMYhP0ZstO4V0{6} zA3>RgwsJ8;V&$*|7qKqBiNIjqnA4?*5uqEU^kqNBavycL`?l>-D4 ztqdum#Xej!>o=Q&=6;%rXRuw8_geE%8CdG>!z9!Ref^?UyL@1D3@M?Gnmd9(kL#sm z3?2T0=hSEq+8$Y_nKb!W>B}l5$b{;NKZ6-ix^}0v7H@k9o%T;LNj;astnzIA?&Ngl zXh*b69cM`ZbKN1R{_0#;30Rv*tJ0{Vld9*)ksX7zzFS1d>{HoP5ddD-l?1%ixvk@q zODGZCHUH$2r9JCg0W?;B$|ySR6z_VEV6U}~i*Y~duWpTYc0P2|pz#%FQ&BWHq!_xn z1?HM96%OgzgQhP5?FJQ&!(1u;FVs8wQn&lO8&eRZ33~aV$>Q|UH17kNHBao%PqJ=e zgB%4lka^z6*h#w_poD;YN30PLf7NTm>gv5O51z`d4T(pHPwGV9@$p9~v@0rYG_uALm}^h?6wEEk*f3}E>s_gPA`S?UhkI)PQhc6w0Le$=_F=4+2Fy` zKz~&+zPZEO;8U&>fPIo}V^55vrsj!Kc7N095Rd6ES08lM@K1I*_ue39&nw;H?vozs zewLA{->P63c;H-7!j9PE2Ac$6fzDE%LQ$HmAPyGR-0 zg)EV}h{^(ymJvP(GC_4he@i9E%cz!TSbRe)iUitcC6n6f5e(FlI*gd{L9)`lkLl)|vWZop zIYHp1qxonz&u0XwwQMd4H>qY3b@oV{h#AN?K~Gxacx>S9hL}_*!4`G5hF5d*KF-o9 zq}w7FFo4x`k-`(UBfx(Nq}#d;Ct6?wyc?=TGi-OQ?5hg}U1Uto^oZBr7;EwOsY=L$ z?5ZQ0RkeSUE@K7q{ZBu`;^F2=CAWx0K5tIx=orQvr+=x5!Etoi7KDP&;+^6j@msro z{Jmo;wIkk@Su}OHEC z+k41Qvr9oSSDBR>)Vt&%-Qha!28vEo`(@}`hA?3oJ}v3(>f7m+d6}Rm!~m8?^FNA9 zA||70kgTqEr?Bb)2FoyJK=3xe(J0FSoUsfbpU-|G7(~{Xn4}mUJpQ)ua-O~~HFC=# zt=tLyJ*^PZ6KkzdGAdGz1sMS>bL`dJE2J9Y33SB8?dnIMG;vd>(fnb9%t7u&{)MJN z5uQ$eG)TY~`tI%*_FhRgyt#`z7LfgYi7W<++(9A3J5 zx<>uA&|FS?+QVBk*Wk3E>=30IM~vv4Ji*i0R~k*o8JQA>{e3w@l2a=(cMheND>fu; zB{H?P1$!#Z2eSKcw|X2u_m-kKV>N>UdC)hLo7^bM5W)OjfAbE7fZ(ob>K#Mw#dFvXCD!Da{40J+F_hO z@_AT+Qbo?NON?0k2denJx6*?Ax?iSZl5HavWYwe}s!rj#ZE`h9|XmVw07%Wutp}A55vVYE^X*^UU#D*P|u1 zhn8?}T80@}!WIWa{=G74Ac62&xa8g1(G^T4s8Q(^L7G$I&Nrq$ibvcKzXuO?vJbHI zMOs6YtE%iVm)I}d|H&J*coSbf_Fc-Y$WTN!PQqa>;n60=t8x1S zKaSP4#aLV8Oi@F@yNuj6Z`1lT!rB|Ng6OUEb@SJOxeZz&dhNomE6v0Q(cEP}Z@5eSZ(-thc0h<0WhHFxNioLH=O53JnagP4%6GQWVc}K@y0o z+cd}oG5sMo0!=;nZl9diw;LMAtC(1>@D`kl_86YTi0vBxY+t(+SbF6tGAfF^rnMG? z4DWwD!XM>tu4X+z!8^u!11(5+1OB-te05cIWzmC^wN`apIMj22}tv!rp zq0ro+!KK4d#n)q@zHd_MK3YS!!_xn=Hv?# zYAZvnB4{Nm6K4z2jcIb^8xmxe)9jDX%A3C)N72fl`RZlV2&sYdG$r%v0Zy$g+32QZ zMf-|Oc(h`K4+>tAwN90-%X|0OHA(6yK`mWMExr2EY*^yLSZr#9lr`t#VF#Iy%$i4C z9!Ea*zNE&sOoAjJi6PLhPKLIIKIzAfqTZR?Z+E~0V$TKdaK25IjHYUm3Si0Qa0&Uy zw`*;x*fmswCm`S7Y9S-b_NaeqnPu`z#s+*yrm%e0DeP4+2HKgek?)!)55V5$Ix-%9G?KGomS%N;?s3C`AZ&w=pqbaQ-6l3uZ2I2qdaRP&u%tv zY!;EIpbdBVcBjHD)Jt;1E2yhuSd1cN67m&5_do+4r(h(2XBLv6;TVjf4hezfX&k73C3FpTAe+^W-Z+nNvZ#S%tg+g=i&3YZiB6r_3_ zuVFifOJNp>JcHl4k1v1lu6mc?m0~u(`AWzw47+9Bpcs#HQusMIYFO%RDlKiz2u^ZMK{xNs29}i%;;r?jue57UQ4yrKyt6^QV*&W3BY!LddC&$00ClK6gC~$xMq>&?MoNOh)So(} zP^n%w0qTb@XPf7G#@fcRXw#@egqHiC<6y5|f3;N(%W!HkZpG>+ds&#>bQGHlil8i` zX}?{EmBsm^M)hiH@a?shqvSPjrzk&{LsE-9&7MfcFzvkmuWu4cnZ%8&Fxn#Pp64pX zMwltR2%f0)jD$A4vij^+-trp|_w9XE z<1p8PjfYx@PZqs7h}^;7DW*!uwi$B{e;aX0zoSM%=bX%c{58Pnsq`bGR*`|~Dr?*S z<-zo#enbXE^dvNsl<#lk+{J@&{j__@n7to$hdlM<=Z2`d;(}si{D8RRt$L4tzM5nN zIb@usGx`VXMV58fM&?N#P!fFny`Vx(?lZ@(t=aBkfdn--LZ*Mwlc&afZUD6hXm@FB zqWG-Q)6JT$NG6_v*^9o1*K)<4hjy@Cex?;I9Ud6k2DZHp64CW-s!oe(&|&NS!lhr&~;o)Vnx8n_Av$rkrXd^j(n6{q=gNTivhBbe3r; z`&wQ(``wupo5_eyt&6kR3W?#$NT)GcnPm<~qUjvCi)Vx-?+vu+w?vEKoq=Kt+?a;k z-PEb=Q_j!_{c%K$!zbAM=h)P@lv75do%#4-K=e{Cr#osO!PanX=|f8r{%bOc^zeO4G!h%n?gUG(Rjor>Mcc2Uez+Z zotW*HJjO1kz=57`_L>aoDG1Zr^V*D+kGxg9y3qnKM|YVE4_%*hs?A&vfe0oqQ%wh0 zLIvjrHz&Z)#gY=Mwo(j>zO%)1HdjLApkGm7hd$VG6h81 zX9WFa4+gnOT@BKPk+JwIFAJNuFW4R?XA?*1qWkAo4xU=M>b)rrGg%KeR%rlN+>ahs zy8Jl@LzvWkhQ6ydTenaGhmjTTqGmlrQ`Cnu)A=(--ZXu5`ow+T3Kq9BtNi+!#pfae z%B=uXfC4^KDqBYt3ldN}FRlC>xc`Ra8a@Z+Nq%LqHjA$ww?a=?wa(?gH+hpF6$4OC zxf>0hSp638E?F&lPik%{N(L+a;m=%Ij!pGI_o&fjrOw8~_?kZ{^AUDI<*m%zvOS-# z2aj`GC*(lpzE|kDMG(tR>vZYrKYET$HJBhd)oNf`+j0^uE)j=%kY^J{L&=7{KQl(N znqUvY&?`9gwCQx7nVg6D2YXhqTypzB4o7zd>(Pn0)0N&}NpQPd1YkIRc(=*&;sf7X zH5$UZRryAT%*0b$X@XesG}j@oM}pm-Ew+!Y_0_l52)76|_7*{%4_wFHDDqtgoWO_rnRYpVt7e3sx+Nuv3%<#z5=)22+|vJu_ktXwKnFzE*d8sJ%3`ha+tE? zR084r{b!WuUez%=y$O_trhrRI2NDYfvcv$hX)QAE@Z>Yyokz2GmK**YL6NiVcDNYR z;-<_RbQ^j$D@((M{_B(H+;+8Q@U$|yTs2o(UQ8Jh_FQ&yr5>0>$h3H+7PT4RCs>?X zu>Hn+<-xBW_xK%>&}`1Rp^PqTWiO8ynbr7V)U=S-Nl2~u!@wEsZ;o|kW2$(se$%y zEI8z_H$`j47CA@@{^UqL!kPl*^qiWb>i9!A+Y5KsGpQY;6p61!eh*VGSa~ISyDa#1|l(hS75mRF=qNW_UD~G?q2qTrz#F|5`L-zJiKOykWs*=J6P=jne+yZ`?Ae{z`rcW~@K|NW2uIdgwfdeV>=toyg% zMdmf-p(5$^f=|yY?_B>dzMrsuk1Bsy+w05iVA(01^X~=3yQv1As{gt7zpt)*s=0TG z_xHC32Nj$D70`V7uF?N+VV`RBy|Ui(fFE92**m}QW*WJq5F|P7O8N8IMmjz@5I&^HVq=Qd?ewdu?3`gT1qs)~C7YtTWnUhb} z#QLg7<*p%g#_6q{ThMPH@heP;N1?O(5@D|xGTyPZWuX+GT z)BUxEgk`HXT??yT$dgP~k2-%)d>wu)piWM7R8Zw4FcQ~!Xsn)7zEa!O@t}`8{6Esp z$FRp2E|%W5g*Cydk4T#>M&?9tla|Sm=u0V!9BNIFDXjSWhw7qwxvpJuJWfh5gZ1rX zwmqh?lP4D91VE~t?W7Bv%*BfYU0D;=dm$BKGM0&y5mPe2I_ib2IawS=vzmr-x)AoE zUIeW61g{X>p0u4`lRVgVe=z5ewJTg*9tgZkX){===Pu_HivUHsRw}zGau6 zCgLls4FO@C)u45or;jgg%R-Wgye(WDa4Yp#bgW4UI#r zi25VFzSUvPD;Z9;?9V1}RjTu}B4zwjn`U19lz1mM`MiX|)@@!fb0-WDITSeUy9TEp zYgNTg7<#vI_Bt?ZxiU@l)0ue5HMzj8{`^(>+eTsWl2!JmHmFLeF&P*w@>ybAI8ZfB zGH-Y&m|6?e1Xivj>|QxO#;L2sTS6)SSLhV_EFpFcTR*H>_%al^^BjWswFYs4pG2*k<9tv#Cv<=n@>jeg z!sXu!so1d3dqYaXfz^a_pn7>tk>I}({QZZY5zLQ-f^b(}A z5|A-=dU%{%n@mq8?tcrw!8-}Xw8&~j-)&G)ZpqYNCb`fl{#E>zncq7cwQ-R_YH*53 zLgReH2djZlC=N@&YB)>RWW$K$U|2L{`EfR_+21`o4MrQUPte<=UaCnx+;=gv zd@YzArM-0IOWhwUR^#Ul@_YE`Xgmt(Ct5P$(>a9*B_DDc zmBbzVP&Snb3|^KIRcq=1nB&LF_s+savD@}!jViw6f0#eXgS7!i%{#Aq8Y0L&Px8;;sJ?FFfk0x?Y492+voR3qmOjkZ)4{D2wsH|FdbUk)uMNhNj-8*nnJ@5@s z9aRsOZ>AtPWcd9`KX^^F$$5gB;W&F^TiijWXgSS2=eJDsC8a%O+}I$m8>nIHGkA{u zBmVy0NKA|v$QKiN3uO)Y7VX|4i7Az&P$RP*P1vCA)_p$a8pg;+FXz0*$W*mdY6A(B zH0fNBh)kV1fVo;V1g5WOjr<9?nk6;)(E17cE|-@qripR9w5~6p||#uhW$M{O~UD(q>LS#X^x94)WKmG3oKa0cjBwM=Uny1s^l?Np3 zHw&~P%rozcxy5eGY|vA(F=IN#UER#J!s#abmeOwz0(a(`T%e@BCwuf%qe{kdM6~}% zf`wZsM4tm2VqF=#P|0VH(IW^k4chFy;WMFCAK8fIn?AHvRWYD6U5zhLzNz;xXu9Qz zj$Seh5ZhZA5M&=ou7%F>x-766TdO`xAX8?W%VeKDJclPZYot8eP4#zl2J0<}W$TGn zL7hR8$DAC@pTqi3^U+aGtB%jzohNF6(M}1RM8_9FL@CgR9LjAtvv+w7Fhg=UfzbD& zIQ>{cqR-ImmXYK5xu-15c}*OBx3*)<;OBD&T)gui8tr5`%H)2*8rNal1+8`88Ds@h z{t4$)FEn0j=zB%^^HZHe8@kZ`@0jW_#Y)HHSaCB?hGHu;-LIVLlx%U6!9u-hr^|z| zE9*kCQ`y>`Icu*X(t#}V!?nJ1E(9CrZI+Tgo29pjkFMBhqV@y6puGm`D~}6ZzZHen z9hx@rH(wb%vG^Rq;<@%pRBuCLD!iy7sUFTI7uTQT%!74s_hItU-OND+hhi%R7 z!{xdsD`O-Z37^EGo|UT$4*W{?Sc|Y&FpWN#SrS}I0ur`!oy-4R4;Kn1wk(#$52C!_ zD;~R{Y3S}$lRx`*z6?!TO2bT(|NLj^zi_>HXJY=>)O!C88jF)>+J+R23Uqz7S8o+p z=q=y?cRbc^uGiZQGAb_~++s{He*Q=D4SH$il5iquoq_fxvVi>{HRU zm?*KNC(C&dzB1T$MOUluAZP3=v<{ZrXl9fNdv1#nt!a_;X3CpO?FiGog zpu$oE-AZJ2 zk6CZAaka@P;WY%XhXzbWv^T#aB9@+d1nT`9`My-+5E>SPi7$W%K=?Daa-or#zCXhX zFcIfE12M2z{7qFQbVXL6IJZ-ltK#}lePs<@?QYI@#>!m#b`R&@MXPihc_D>a4+cBB z-V1LEKs>liJX&gGva9Ot0)s-Ig_~G<%%`S^zPzj4hKBP43!f6V;!zkZkBqhf8J{(` z_sS9mTvQZwVcg9M<4acXqBp{{Yw4p!tq?EoaplaiKSDj=OBZqLV0}F}$;Jl{f*em@ zY_YUWWp3K=SWhos%eH8L?S;4mkI40S$|8)kH*j1 zQcr%5zYaES?YlI4p-Ta>(pSws<+zH-6z*(zDO|>;N6Bb*G=N;_Z~E}@CAz%B92eAs zGAPt}!Q;mqa1SY0$+lp3Dy%Y7Ve+$ZfxdERX-`$t6e(oJXm>5(4Z#`{{u4jfV1q~| z8Lj7nCOf}E7Wnd!Fdp&CIjBQ7tOZqSab7w1FIWgWZeaFDV)(g%<9d zPq)YW%Z=t6*QEdPeCny+_E@I+@=jnXXg5H;+;*Qotyv2Te(Asqj$q{y^H=FTR;%RY zFG*i(ZBZwTkd=0{XT9P>i@oM2c__abgg%r!2?elc1ai}UjhU*v{;hoa3hvVooS|$& zrP@>)P1O8Il}(O11V=g;E^LWEB;aW%C=n{gKXJsEDZ9$Pbo`VP)1{S!%5kz!En1iNSm@949(^ zR(Ym}#3E*?<-HR9Q`h>~PCV+86&{L0rL%R*dYCqJXuOD@zgD8E@{4L#r|Ww?sD(FfB71dO`m{{L>#VxbUM_o>mY~quFDa@M%2f6RqS5Q7_CoYym zCe+J~7q}Ey*l%vutZ-`7f$PRCTIHXg9M``4`>s9o;K#xo??WmVzLTV`tE?redAZDt zd`|LBF|nNmry6uz8cP8-0gmjk1-TEzS&^78OI}kGk-p4Afa_AWfu@<_lHC)MR$i~u z9$cBVxv8;o?tVa98&Xm0Jm<=Jm#nGn^M(NAe8qi({5?3A($BRC^_=60+}JY}M2 ze_$0P1*AJH^h!8(|C1>ycW53s2LX3;t=ZIDtX8*a#rpIdOsN=}xpZBjC4beHZ;F9= z_Ri__nK#K=sG4xpO^D5;hs$b!wC!Tl9vu-(P*O-hQuC=?uSBLQlc_!?) zNOnNi!JhEJxFqM*ihUG4xqUVn|J(=mS?9J`_=oVxTE?xUf~0T#e-fN9_{%yGrn*Mm zJZ)#ep$f0=M4Er2nF+TJa-;^a?-)@)8rtzO^qUr~=yt_55skQJuh9*UI- zu_c~@#k{3eC_n(f6kRL1!k&XXVt3+nvI$VyMv%_6UI14Mm1oJQJ2oOiCZ3;M0<*r; zd?V-CdK>wgXInUIWZK+JxwO_o`H*VZ1Cls6I6TX0Fw3o4I^aeVx$c^{0X+l#%L>$! zC=h*opRnoTD*egt-(TWZwA6wMU8f&2Z1;s-8l7tzwqt0n=PU@HXL$0I_xibr>|)pV zo2GqF@}$-@2ZQ_l)uNlTZCtQFW|d|1lxAFHS_hrm4$u4|-GR3FVRAu4tu)lE{S|Pp zey>(-U3!vw^CgUJ_zn!+DyN_1iOan#GZd+TaQZc`@V-x!Y_hW9zo@)O~1|N%KIsJ|IZsXV%djRu}+JK z36%u57vBjP3T@*S8c%zB%TwtmCA=Toe}1Q2mM%d=xG*-&;UuAs^tz>b{j-Lt%#)&% zrjUM;N=wzViddMEtxbX5-ArFUt!A!ic#zqiM8EReG|UIh&;|WF6~He!$L#lQT~U^@ zmwx@mG5<6{4G63zjzjwv6g@j@?z#T11PnZaD2)57-v1zOTb<3J*wFX;vcksr`JSfE z;5b6^?XZj~BH1@m#vvq7hE;E3$o&=f`OUetI6bVUcVnawey1rwh>v6Rl$|^1fA<14w~*M<@>-o z|AR9lKeA6wHTz-L<7W*2biAGFQ-_(c;+sbB`M-f#s4j}jTBX@nSy=n$ui4J&xC4Pc z{@g88#UPST@MvvU&p82EKeTg@;Kr~=92OBrX7Li;r5W$G%surxcPZk&23{rQqmJ`l z{3(c7b~E7U5r&Rji=tI8r_B^L|HOWh8*&>Hf9tIc>=R9%-+Pfjk1?#5^)xHCaU9WzZ|9Hq zV}Bu*Op`S=D9C=WU{<8rn`yuk`;g~BJJ^Q3-u|5pTaW2ed5+rWrKQu0E83U=S$)}o ztxrz91=ScEc2iu}UzQ-&a?m!1y33;d#A0*m&c|sj`_*`>V&n3p&qLAldh{mAtZWBV z3NWNK^`l8Uh_!fY{UacNy;OMvid~*QsExK{Dj>){PBs`UfJIQl(yex}#C* z5E$Lk(eH1MleQHWFM-(5@65k&0#0Q?C@bc8LrL4xX53#aj+v|Zjf20Az@$S}--M2r z?kTyO*+co4_EzVc*(0;(r$Zc1pFiqMMakE2=^{6{07Jj3StpD;9%Wx$t>{bnp3@8! zx$MzgmM2KctV^{cmH-l#!{lX&z9$cTF|fWQCCw)x0`e57Gr6q#!)L~DR(WQhvkH^N zl1ZXgrJ4>8ry|Q5cdoyFzeEe`X`Xm;b{6!dBgHI&!Z!`U%;MR2o0CIZa<{0yixr_v zKymVo5v}{)S?+P{y^b~f%kCRHcG|gfUw_q~e(y3Zoyff;ea|abeG~^#*#((^RVu|q zq{7hI>ih}ANA7`=#;Ffk!cbxcIoF!nmEh>8+SR-iHlCoYB0Uei94-x#^&8lbmw^8-ErAYG@05mhGEN^6wra`I&3mn zpFDkUmUC-O2gBv=mpUs3jr1@pRof!C{WTWsm_xT#5S28>&DvQxD8P=3X8bpZQ3GJ^ zpw$=W_7^Y+KJockaR8b6E0Gu|OdwBDTgmzUeBE|hSqm120od$x=$7RXvKrM8(cbqg zVsnBTlT66XN2yIGE&ibYW6EvxCtpxOMv)kNxV_3a#Kn*OT+vbn$>pvKBO8i`$d-KU z?WgMFx52l;056ZT_&k<3h@YR|Ml!6mJDO0GlA_fHX9d{dQ+>TLEp77d+`PPl5t11; zZPNM-@rH51x?&Y2vSX9+R<*9lnhfZpmG-|apRcbs)g(9NNlvXBm}VVnnf8GpCTQASp7O?Hh?r%{p`P5_{X!J2a;(Zgnp7+szZ>Mc}xEFSUZck7t4+5Naayu!X(@`FXNm|5j?fQn$0O23G z+?tk{u2mkeJ7~UX3RyFkdcGvI_7!_OyY9p@2H$zC2FnqL#7ndm4@K}a0pXN z7WpeXp-q;Uti3!G4@x^Ha3~-|<^Zg7#xJouv$xqkMken@FJ5|#j6t> zwmo<*VmsPO=rvBuy5b6=MQddKN$F8*6BX(yUBv}dJ2|}~3efqc4g11HZqVZ9L-!Lv zl!NHh-uFe>^E=gPy}#Ke9#h7&=jSMq-E2KO#AnGm&SMJ>KGb}hRHt-19T#HbzZb5e zox~ii#b&Gxo#2M-E?MVrl58_%BYua3##3bQXcASHlto1X%-|MB`$v}q<}Nn;VQVhC zMBZSW`5IA%7WClM>O;g-)b&XW?VG#59z7T3O6!5k&0VtP(oM3)k^_75<-yNgG$eiD zA!DPLU8m}+MMU#Dg1$G_r+75bOl1Zq&oV6=fACF0iV3DrQ?&+7s%)uAxNS#Jo}^F{ zygkJ!Iak2MG63lUCXKi}+P-=W%6$$>8@@KOC9mJh2V^(vaX2N|j z&2K*UXOc2L{&7YO_TQRJPg@iMD*M}hnu@s_N-ObFaCE)Y?HDP%wa=@cm+!%nvjb&$ zRH&oaNKoT)1K{MGSX0^)RCALZkz~6mwDNv5*9ASE+ zK~hdvy#?YcCC5!Y^GMVe4+n&#@1`?k*5B(k^)lhm`!-}3O}|~OfEJO&4##j7V=ULC z5(*U^USKv)4S7AsQ+x(_%csSqh9I9XD*!)1p7E5zH4m_kkx<`DSW&dDUowtSW^`ap><|z6p!M!EQe_D99S)s{~41IG)82kA0 zJgOJ%y%I@E9hA8_b|^<1=664VZXlz|9a1Fzb2IC>;t5qVk;`>CR%zOXsccw#OWrou zb|=1|8RpV=5Z$Ep@F^rkvI59d>LgT@?G#pvQZ>X?H;|&2uyZ(-2!h z7LG2R6yy$=Pc&%?zM>j$@H*c!;TCJ*Z$ycmlg1S6=UxNkTkVs(J0hu0b|%vrjm1Nn zpk)M$6m=Z(IkLc*zWmtKEJ@5)CDE&Yc2fC{W6)JDr#(&36O17{{4z|Jz$X`CT|Arz zLG?%YdS;reYeKCwe)b_o(;v!27Mhj>%-8^cEX^5azOWnuGVbuTx;FQ$fSaECB;Du5 z?_a)oK*LwBQOLJRS%}cjld{Wuy9_b@i(bo?eOn z!ON`gbJOR%wRV|fL#Bsme2RC_X7>V^$W8zi84Q z$X5RLwP+Dm*bklK;38v^6;my#QM;ZW((&}GO@ZhyiK`FOU2^-SJhE;d)G-dYAE~it z2ZouzQp}IF_+Jn&1??!T?Qw{rCBf{0XyAOt#}o72_UXTc#7c$4O)l?eMT#&O`Yk9o zU*Jc%2&+LMHb@gzZ*Vs;4>eo--Wewgq(JhbU_`Q8{zIsl<;-hZ;^Hz-PhG5QO!r%}28P715VF|d(QW6v>b1YqVKOWr7&eFTTTR`Zj6aXo+ z-JTF<>Iu(n5{Pl6!VF*at@I%_`)i`VY|eAeb+5qCO=p>DXjUma*m0tx1cN)t4Ovdxg{v5`Mc};-yf(LP4 z=DT=%d;X?mx5qiB`sTSESE_?ci4;CL(_ot&Ov%TY=Ix%EI+(#&uwSCPiymomXK@^s zKty5-#o%r_s~dGC7gPJiqo^WC2F~RY*7*3PN>6f z9FXP=8O|hfoOsJ*5ThhfI!MmSFlYyXi!+tg5?{|kd)`6bPuH~1vZhNfvj7IsNm0xs zOLy_q{|be8F}*$wu24VkWz5C#k8a!^fIPl3r)Qh{4|cts^Aj)7`6akNYki!~F=N)# zsfi|DJ<^>Zi{+b(RTX#46`HpBVK(YIoa~Og{7@8x-ElyS*04uh8P|joDgd_aK>9=Fyb!|#`Le5BamGVG`95J8;AU%Qvr z)-O2hTrEX7yn+Pc@XmD}36d%h-(B%K znw~FThA}^^!7rEFro~9HStuiyVUCg(Dp|^8C-zuKOr)w6w-eGH(`Y{o68Or`y>T!4 z!QDQNJ{Nw{!o*PN#fmm&SCGTi1>*;f)llurn)@wT0{;E>Dt#l?aAO|Or_a>cUm z1_;D3vjV-kvA{UJu`k{nXEauG{lnZrs{b*3G|FIf%$}Tq?nXELA?_m%vUV@ z^>tXaM>wHa?5-S1@Mofk#UwUYgTGTX*u9d?xg3yni&S$%7gOjJ+rrkoSoT(;w_q(` z{YDSsvO(U?Fs(3Xgf@bLXWUtnZs4r$Sb~D>VcRlTP{qH)}MnywQj$$PvAYI_9Y?mVU$IJRPr`>l0U4Jzz4-ZA1>I^FCm_Mw zSn|d5+5F$z^81y4-v6KJRn?b{%fPGk^@(6LlA0*Eqi5iQ>3@aH8G&eaI7pM>TBS+4 zR8W#GFmp&IgHG-TsFNx~rd_|k03JcU{(6?vcJ0b<)%0F(%`xbW*B&vB_`P#2vu2*f zC%L>k>0Dk&of)26Q?W|rVN@s^@A@(y+%D?qts}<15PDCQuC^mN_~|`dWt2`$*c^KGI_4P>orF>G= znJO(&ty^JbCMf$gz(|ZEzJDZDgLzoAZc7J>|=^)Q{C}xvJluM zy)K<(o$4GJn_L*Jh=2xDS)@t^tth)S zwAN+X`8@hT-G%(Z*7uZtg~v51xI)n`K5!yKeBd=+FAjD$B-+?nSZ05y(TQ%){|bQz0Vs@ZJr4mXDI#L z=d*)C$vQ7WI@hmpJCDeD(y-FLsiR*qKb!atn9U(u(d-fU+J7(Hck$UO6m0rQ%nRvV z)_mSOpv-8r**5Mo$|-|#|DxHVmE|=&T^rD}Q0+EMQa9mYeEh(VdZ|x5F*@+|0PG*0Pd^WLEBZ@e?^a$kebxW_sX{wnp$(ho7yYkzQ zFBCWS@nYtR1Dth^s$VguwmMNi==1M}nsJt8B#&P|hdM4|81k<|Fnf^LG9SU;cT ztjT1omF?F-@_BrZR#d?*E@pr=_Nna zB^FPJ&aZ+q4s)myr6BBgWXZ{kf$=llji<*9>7JFCUomc1|5-(3rwn(kk&7DmyI3^3 zY+9$p{Mem;)_rR7f9Hv^IQ96E03A{F_T~(rmLX=#7R*-IbGwZxfn^h#duUil(CeQ3 z(g55|fGoi6z%)|~2Vx$Q+dTvW^Jf=F(@{XH!X6HGA^UXr;r^eqe=q!cP6_*4d{0YO z$3X3m&ikY;h|=hvcZM2Y@4H>SE8X|R>7984bACdbqP!CSY2g${o~M+R82RMZ0abP2 zYgb)kd8{BDZrUPBlYS)AZJe)t2(Mdkf&K6%$Cz|9gqLob27$x4ZIy3VyMYhY!IJs5i2LCO^uQZV!qT~pfbPpKZOFY47EK&}u#EG1m zj&{`<_h0t!%%V0P9`yfCT^&qvhjm&M;*V^- z*ZAABxue`dZr{~pXd(-Xueg0$OmEDw|TC$?@kX=mI@|V*BT3Ng`0oBW0d;(n%T(rlhAh{+$@{_w#i^Ij)l@{n)Ka_f$b} zq_ktRBj5dq;$5Y;`YT%aPdpFl+C52Y4J;}owJ$ljBcSB%c>l*x{U*PQ9569nqCr-m zpS-^oMV>&VYps1kc@7k+SGUb?UUaIH742;{l7MMF$og4;x0bC#5?KZp*SU+#U*K~& zs&lo#`{1v?-iHL;lSe#&@P+TNO~_&3t#Mw9v05hK?YqNW#}LeXHG{Vx+ehFoEM$gud6bT2czBMNYr8MY{k0?_&HE>s>44&9!ht!Hi4sKdv;J;}LP+NM>qDs$FwR;#y9U zD4oev%N@lY)*Dblpc(B)N)Rf*Qp|S1u#exv#=}~CQdfa=lLl02yO~z@2|W1o9I)zcTPO8UF~dA(QXu*XkDRYy>8Iw$JRRyH+lG~GQX*)+i zqPR|{j$p^~f>SRp1akhM_?~4)SrpXkm!8#Vwap%A+eT$m28#6AAZ|pN7U$H80p*h^ z?<>`Q7I!{2ss%8!8b%rme^*=&`+RD!(4!9e4Y`~ebNs?n0YFYC!fZltS)X8SsTn6+ZKZ2 z>q(d&D*={VYMgnf>HSBxBI(Hn=Et5{+}EThpG%ZfgK`s_m`oTSLv11kuy5O2eHRw9 z4DeHNSBqA-M2CNJ9K)Afs?ckcLGd(#?ckjRFxu%(Y=$2BkTdDk3Rqw!h2;?m( zNdfskfkzI486TZ=?e%{JhZzBsrLA902EOPnSlTM-eBq z2$hFb2=(%($(Q2#QL1@2yvr}fs%Whhly_QrjMtDfh8wcR>zl5CLaGnif^DOeGi}|s zF2}128R_Mx7+AlkA+b-^_aT*6#hmD+n<-XW<&eUW+R`C%!-T!I93K|}8)B7FuJc>Im}3gs3o)#DzRSM&+*57bv3?vOpk843O{AiT^3le#b!H|Sf5qlT z^rqkK`#S?65c3#WI!hZCQjycm(Luve=xw>9yo~A~2Vr@+fw;{~qsPMZX4l6oa=(hP zyF@!70CW}3qo=iNz_nH)hC&&$2 z_X*kCA6?}1D|?vtcy=Rpm~s+}$ywA}!Ki)sQg>B6xZg?c zeoj8KY6|E3h>!NQ9RP0$#f7aNC?fPOuslY$y`*O1Y7L>cwOwD0ZBQ-tJY|QRGzf`q zZ$svZaJgl=QDvT5YIs|6mp0D}KiP%bSEY~?EDHmOQ^q2i`jJ1t*~Y#l{y`csL_jxG zzLT!hY3f0qJ`Cp7A03;bE*}T%QA@p8^nnE z7{o7_hu6hCc^iIU>Nlyip_YQxhriZb!<*7gSY*tU%U+vlA|7T+ZnUm!15eL0o{J_n zXJ14d07&4|9a(Goj6$f~aUFJ=3i_caF4)t`b-S+##{ExEQ-yAVFo1y(DEJwZi{CdGa4Q(>qNs6~sVt`MxYp%TvF zEb~i7_2u)7?|w3d9(3~9cP86+1{G(zGtJ6QP`gYC5XZc|07ndlwMCUSAr>IS{9tx| zktSEO!Pmkb2xvV-W*5hnT1{xZpnb{rBYztr}h zs@$KsU`j5fLhH`#J05dnO-)wuqvxWI9`Bj|dm%?@J5n29+CG0}V7$9f%p{I9IEIyw zdEG+=`+6am!(1jiA52=oM@gqUYDrEG#%P&l-LJC96vH*N{Ll7zE#A@}l!c|`Trc>E zet=8hW*H;$!uZ4+i|`(4oQS2g)xF04l^Rn+iC4os2s;nR=O$@;SoDl#0fCoo+_eFprRP z37|0Q*tpQeTmG@(K?_5MtPz2%tyrxUcV6|`xW8mR*DSu6ge$Kb=9!GQ6M)CH-@Y7JsqJ#QJd8*^`JQ?XmqXf-a0gr9i75T<1K$})R?vQ8|X|t)DSI6{< zW5?Om%WsyPWYHy_Q_=hhA;}rkTS{vH1JEUdXnAwXhZv#p`9b^cyU`-xa8BtJbhe7_ z8WbtzVwma+W77p@8R(9hbAm2B8*P*RD5&3yyC7kQsP)w;z}$1zvDcitw=iEmN;nAtqbU zVpQiNJ5;Tb9v@s27CG5J+2mX@J3Oq->!2#|#MN{{Zk*}zmSeMa1g(X+dp<#FEGh9$ z`dFaWIW}Bx6FGBwTLL}2Yi71>7U1A`#+nUr>DC&#I)7_o+@7i#O5SvUTZB)!j`aJL zA7tv9;a?M9!mq>nGB+GgLp{@=ih1|fzR{f+E9awCOh4gxmcT0V{;p$-V^u9Kd0$q$Uiy6Ysr`=LHoNtRLGwue7qyjmfvxLkPK%@KC-eUs=YV;G$w*cwYZ0fa_` zSVMRZB+=R~Zr+_hQ3gQJMywq>siF`SKgOEgrX&go$)&zFZ2VirXZqdt*s6VDf0U5y zGG+4ub%dpi1U6ID&K(;lRR$)y&OHw50D0_}?YW1e{UDh`ZTG&!_o#M=q$Y_=95S^f zI8MhnGLSRRuG%J^w4oVHz*#d43jd7B6}KEk`La=Y5typsg|g{@-~89tTeYFIFykB%52mYh9PbD3C656pMtgpf0al@7>g*Kc9ENy=7>D5EvvVfDtUH-5L2dLJN2!s?DyhSAE6$LUqb7e> z{K#pG)5CS<9>2{@;?Zvd<60B$feU-!)%h5x3e$U3Qs}1Z)dN{lPd?{8I2_}{W$-%J zC{`?4c>ri#iV(G@r z5pEL%3A#3iPS}qQ3g7v}&T29+qkp;@PZP#-@}n(2?Ok_1UphpIZ6ESn=2ox>0{7gi zV!w6?`JC5+1`7o*qU$=SVWT|)S%uaTFw6LHjP^a#HhH%gTj^~S^wqq=zVsR^1TfyN zk_HzA94#uABA%pSI_M#x{^?u@JyZvsmiU*{Tvi!x(_CeRzZ9!N=>V}d@s(xb!mG}= zDjW0JP01SgeKTrX+P@Uy0IBVk_PnkPh-<@^>q#bHrm@k`Wk#-_2U|F6(XLqZ21o#C ziv>Ty5vD{KqgXw|n7BYGft(FCTHx!|I>nF3=;y@IMMLJ5g2rQzk=xFqQGH%s?ZfDe zxb=s}{t1>=A~_i%ckVsFv~-N?eYI+b{>J}%TU6$tWp2HSd6TZDsceyjKT}L*y}-49 zwQ9|u+2u%PUE=BK#l2Sbt@G0T<5tq6J)F}NvHkASd$f3^1DiR1sIrjfpkp#xb)tel zX6>PNveJ;xsMym}W_;2aQDI;5r%lChYmyewRZ%A%S66sIxK#+@M@YmXRsFyKTjQu3yr`)MIHFcM2)=u zjA&zPD_p2`zn>4P!B#^yKk;ie>4gpcsC^oC_fp|;=78wLG>=P&d|`OEFu!c>ZBC`h z3XB1PlS2y*j&lYVWFD<6_;~w{TaN^a7Bx%&+H>>lEmgoI)s3KYqy?VZlqsvIzd&zN z_%0&4-kimbSrruDFs+9Hw5@PhE6z?%Ps{`ZUzBGk_(N}G@%oy2hTFT!rdVQV%en0R zYd}%(`raPYvev?Fae2(t=e8r0Qf93YI6YJi9GNqRe}mj-W-SSU_m;~iC|4n^dXYM~fcrpDx@@aNvPD9#=G)^``luX=6^liQfPj>f@ePc1UFu)3W>St9eNY@g&F(G7vcwg~#7ayHv&ej=r3TBFC09^x8U% z_C)Cp8d8c8f9R8u!=ltHCMy--X}wEC-K%h=+up|)B%3Ic1x#lf?SFUv$%L7b1AaWG zS<2<}(b$5j_bmeqd7^^P9%nWdyi)Zr_>z?F&hoS>%Ak!ej;*Q5xjZ3VBM(3i z5m>5--Gg@%M>f(j{i^g-FzE81&k$V+sJYfFAnnfxq*k+SJJ}#9OPXiTpJWj2p3Y^Pdg|)-wyjg{Y6_Nn z_0e@rVSt6A`F8~(d!P%N@2(VsKQHlQoGZ1auN+0~^2pc~?fqMg1*#8Vt)_cs{x$I?65jsGlQXXhxAi{i ze|}~EF#5)&vDO7hK+%LRUx%OY5d_$T>n{lQiMrmt=kny&m*t|5#j1}+xCv9QmvFen z@xxLuL-1ZeIqnHzugQ2;)_jB->*DcUK_=b|kzB1FpYqJ^$E@MeaA6qC)jR|)(GmJu zHL9Fe|HkehMC*3RFO%0J0goPZvqryeS^Kq_*m(abHgp9m<=uZG0vv-VF=qE5vtJ3z{?p_}UkDpsw4(d`<@183v7 z->ab6Qm7Z0Sy^keJ+;oz#w%?~Mz_{8olj-22qj*z6ig*_xHS)6ZTe&w#M4t3yFd^u zX0DBJc7cV;(@6CO#_p6NQ3-Gm42`L=V$GU%Qyt}7yPE^x-8TcYTn~%)++|L2IAb8` z&SNJNQ@}bK&5P4_jjwY*+b$^7$S1dAvRoH!(lc*2D{HzD2=^4)QY}WqEv$pHyNV3J z0k`a!LgVNH|EYHeYF8G#c&RbjiDfSxU~=8aD1WXZE&+7zVrb}wC-!cQPY2;ioU`M` zq5l3!>|Z)3QGwW?$(?v>03#KB^}L|@rS!N$)oS|f>9Tq~(S9*gA%>|BN7@{4L`EZ{ zlZ{)UH{#gVyvE!V^QCJH6;#3lfqrSh+J&p|8!B@0@|o#c(}l0S-Yl>~*u~_V0_AT! zsEHWIa-j?@(>;;GnT$>wLR@P{Vr%#LhJ3`_(17K)0|K^RTj-;o4l%W=;2>h4`=Q}rtS;3X6$x<5xNZj2Ob1Yy z_enk=zg$8AfKRwuo$adcrD_@=w5e`QQUBz$IZmvo#GYE4+iBk>wv~6N);Qnr!=sO7 zbo)o8JIx+TMUo!nOqu^!b4{#hRT^m18b^%&X7hk|akat1ZjhEWvim ze!57H!3y`AtDRH~P(6pl%)b;pA*Q%px(kv$GRLX1A^IG{D=rS(0#>eJnGctkLgEK*KivrpGk&Sl z(IVSgaUE<~s#n-!991h`eD&(PM2sJUjk$d09=DyD`o=(H%tQT(w8s(Y3#@i)510}W ze=c4)>6#DcgV_%UyX8te0@74_?xI*i)4jcXg(^6HQrz0M|Mwhr_9QpvzVxo!<4>aF zwK-#~jeWOIE9@V>_ub}ooU$_dco6$3B|$@K(~5?2Jf_x0@>zbN)9nR_x3r4Dw9Xwj zj>3F}XCaE-N%M>&62y&RWJD!P+B|t;bjbBUFybMm_Yb~x3&0;)^n5WNcM93BNs*T{?t%IY+n}^yA#aY;d_O8it zn@*ygH+m`w-&~Q=mgSj2>`w6z1hBc)q4;0qhwsFT1HaEj@uxtC@Ch2R7kwH2O_6t{ zR{axEX>M?axc+%J>pHmUl6-oSd;j*Ng9BIT)m3$SdVGCd#+CY+7h-QxE`NTeQGul~r$%aaAA2+R zJUGV^zKh4N(Y})e?&*RjN{|PW zqm>aRpA)HyWF>S)SbhV4=~jqE6=w9rd%e+OK);M#yI|RSzStl`3@MpTj|w${aE>X~ z>D3PzSOWAd^;Voq|MXc&+2h!r;8lfD5Xdv8wIG?Shv5azs<@P`zCM7Bf@paw$qu3#kT_EdBX4; zc5o{$fxaQ;c~4V?%F4#<%GIwmIBiSQig#`msq$slCbP_^1lTpRvt54vq7+P-cs#Im zcu$O2mD15ZAVIb^>C*BVR)c3^LDO?|HqPU-F>ey_OTO63#kQA0+8WBw#FfhDK9tX1 z+IFfIyXuRzB}?f@7qa7iyN+a0Puvt|syLBI_8*`2ED zV9eIsx9X%~rAlD^2(Vszt;b9z(Eglaj3c9I$PQ)O-Mu%&o35lHRRdns=G~P`9G98J zp4JU`zTVi5-cS?;=qhmk^R_o9i1SczUO4sJiy;`*uc2GJ>-0Ud&l+xf$GHHMuU;y~ zb0K_Odvh|FNQ+?(Qf@6R9lS1?S+-FfE0I}!QN|Pco_VEhW6s7V$@O}2QBDVjbg(z; z-k~4USO7`SNoOi_bp52cVVG7#*aEf{R&#Zkyu?PrTIY+-mly6$O? z=OKc_jb!6+r*+IQ0(mR5LT+*VDjZJEL}uc=5SPGz<;Y<>MW}eg{K4?Wbfthf#U#~F zO6vmEs&z8P@n!%Z+=MJ``8D6-F|+*?@wf51m>j5`bYSjt@6s!; zV$TB7NrJfQCUDl5K6Tr(UJ&oxZF&v$tp=yzcMKro;_1s-^*kIpI-RUY@bl?`qa;^S7Z|(XgHeue$5WDZAO%f z{!*AexgSxv!Z`ej=(ul_I4MrwH4sTWbc zNPI5;XiK5^sj2#xAATv6WsAKNdw2R4$yZ?IKhtMtiJlX`xxOjadA|NFx`?*O-pi;4 z_x+yP#`zS!KJQPGm%rTY-S9>6Uh6FKVjDG1^6`)}-b)g-&6-QPVK$iygjl%TQxR=H zoT_AS)wc{W96|mEBhka}kT|j^VvWL0kvWjlNeAyK?@x|8Yggh_++Q55`>DCd_|fPN z*kr!k%c}WdXE^tpH=MWxwp?GddduIT5*D0;2Bc@8j1pz$ZIo!uXND;qLQ^|&~R;g zKmtzy@nEBp0l3K3b9c1DBJ4SB&nlZaakRs`b0~N0(STSNeqmp=2Oc_k1`NqvUcL6Ft>(#6#tU^?7YuXyMFA~~I zGbHRCz{b;X&lu!gCZh5{cYoow#2HC*VygMny|jo&p|Hx^Xl^jy-8%UpFOYbewRc_G zZXrEjD9*BV{b{F=9@P2A-UOoc58Y#r-qv#{gTU1oCQB^)amhOz$3Wxz!`~{dI~l2I ztp31q15H?Zg|Stnpm9!uxKQTqfXD)sPBRrL?6}0dA+m;xjI!Xzd51<%f=TTLEdzZcFTV?lTp}&nbT;Mff zOx;=Sm7b+7oh8K&co-d^8_-?A=4u5jtv35bNr(L&r!Y94 zZ3_#|Mk-lPXYSKuwI+-s%)_L&lhc@*mR(yz3U%eLX?yZ*v45#mWzpt}9j!Cb0(4%c z4&5zKAF{jKxi;9s;4uN~73LO3cG;VDj{RcGku!_K6J!R`3_44tcx;Scxw4vGzD`<$ z-!wkOdDH0L1#E;cq*fwQR1~@kQ}KB%BP}Cu&JxmipL`t)P3`5dR(~eJUotEn1H@t^ zT4J7ha7+oV9+mb-OUCruv@`aM6PLmPD8nzFN}tB-J4GYH3GHijO(b}VQtBda6TR*O zdEr{U`e6=^qGsOCQL&BW+z_o_#GPDCjathygh$iJ>#Jm%+M4U)0($lzff!cjGTK?V z?~O6X86&;n7V|@FwETw7(>EG*{Q4&HA)W`*CGwwOj@piDDQr9e(fFpk@G1zvCp?v8 z;9Bn59`aAW$6?2nY(K19IC)fn8k-;#EoR7xFIUv!UV4_so+}z=2wO;V!5fSNd>vM1 zuS=8?_hI_~sAG|Ufi#{=qmO2Lxn{{J#5Icykq5RLKvw5c&onEp^|Sez0+mL znl^P;T*qxLH>ctn`iSL0u=g80T3v7JV1_+~nKw-9Gi=o3PCy92Gjk+q*d2!ndI5oO>G=^}6Te`8wtD2-FrRG?ut2`jeg_dn zdcMT)*ay112EBzCb*_yR0KO*b4IQ*a1XB)wxR)i=h(ZfH+!^4(-d32mIUQ8E673wS zMZZupDbP#9rTw6tM$dyXT9SRTSpc99oqHMC=&v6WWjg>%$lX#td+3GFvMvB83Q^*c zDw?xzy3GCj(kBL%Z#)l@j%+& zd4V1^NY(3wBk|sPkn$EeL+~UKTr#lq(_At0)sFqitCQcaBnPfYFB|w}ev!9K;Sbwh z{-rpA3;v}j{$v09_m_P-o5lsb1%)8@{qkQNDHgQ@YCS4B0=k9n(6aQ++`IYp-ebaO zfz*eoZ+OJ2$u6IQ^j&>UCkx~qQTXvOx?g(j zI$5cv4GDwTd838R!XoTX(V~byrwXLDAT~FJHYVVe_j+JY7HjYl7>Fu6wGZI%zS1)v zBtpz)kkjLtE61lxlNtDLtkj>53+%-YvTFI@fr@`AXtSD}hUKtXVZX2Lww&xOfGvV= z5AAjmD)uNRpZw2yLUH|$rO?FE0A@lBMf2?D%N zKB1EYnhcGa!1SvCiyI9po<*U0eT=&4vA-V%BGJFi&b0H^pHExY#s(zr%5tcedjpr_ ztH*lpyQE08jHHi7COT$pVlcXfV#t6%a0vs}v|IPxTgTsFe<{BG6#h%0 zMrP+_ge%@>fijpBfcR&v3V0>IFhbD_T}neq3V-gA5o*m{KJ8r+XeFCmC7&<=Bfc=`K}E0Pqs4M2wG+*+CG8N3ELrTu_{(m1|0 zDstQW&w(?eP@y-w0U&sYKZ|IVy2$jk#DpAZVtPbcjTwD<`sHZh2sN@h-N4%G?o@63 z6sg2f+s9RZdPF!yo=1x;d3^vQO(DN*U%Ut}7uS^|VSyiRrEk=j%Ygv_ru^-@drJ6p z8vhI`BC*UE_IV&wHO2gc&{X`w_*{5Q9>_g9XfLXsSd5Qoq413FOB(G%7swtmG*H2w9 zkQn$q@dTme1dn^%+M(l`p7^kITCVO84r3J4^sHnNz4EjYxh)i7wTzOaCK!!BKn$~K zG_(=9K6fTJL`Eg`$_hm6X5ZFF`)y~Ec%DPEx%+4r_~FS_$7l(?jbvRL8D?}Rp+lwm zp1^)B4%J!$)aZZ6lUSP$GFsR1RO*er5UjkokTV`mJRGRO?b)=dG#3<7h4P&PNktgY z<^!utT(2Wsxcw!Ju0uz|``NrWN{0Ap@vQPt`%8Ol!%-5v0kc498l$#ed2wj~?`=AL za4{)?ql@J^xog@Z-K19h3|g&2T`?5#vAGIYT~)JHL!t>oBbN)Z5#1 zJIf7j3ve(q387Rf$zJPUW2m$T9NEs!xbX(bmDSX}Twq_aAD=AVxbq+#S&|#-b;2}1o44d$(7;dC-I1JT9l@6>Wct?li3K&-}(R5x7V`9pB;{AqH zoCjAwm-p}>1c;SFSYdf^k+S*nc^*;tptVs`hTkFe%cm%*x)Kv8pnOim!TjcIgHY5qlvOwt%1HB}h&l&iZ$5wERZyUXJan1P(;YQaLzJHRD+cV8^k1ct8i@GiS$$&_ zzX^-5e21d1dOROvmoF!prq`*%n4&njT5fSp3-nv}Ej$o^rN+omMc_WZ%gkETl~|vD z(0GNb4ftog(o%!3+{g7%O>N`le96hMJiAHj5zU_=_x5Mt1uw^!_-b>niv@s9%yMHJ zu61wX{+aZ*(U0EfzR|FD>7m>rU9&Nqr~@I(o^AzvRC%{;?TU19;R{3jr5IZ>T|x$z zFKXh3uPaQv4A43yJu<}(hkkcKTu2+t7p$ik@QImGClgbv@YM^25MUu3J0|g@CRvUC z4P$I(m8tvZ@uKW+>WN>O)Fu$LhyZx+>>Pb*kP#<4_UmN} zs=Y^0z9v3Dd}gw|XGA=wMkv86XlS5jeYrJ%=PHe%@*UJtg zOv-d}n}ZSB%e={q3Dal=h_~GHx!mf4p1d-Zg3v;zkVx8(^fWzN^;OUiTS&%twzXUA z1vN3UEYxL5zhOYgQfL7xU&pj^XDEkS{OdsIJT5qD~}v5T=Km z>2k{T>I6~r6ku!kYwY>9!kJv-zGREaB;dX=Qml2TR&1!58Aw1s@l=~^GR$W@_&F^n zhq}3V+Ebi0V1q`Vt{Zx1t=Y<2XkKI=&x27!5)Z*j{b{07`%UNF1#>ZOB>EY1@t( zN0Jlfp8kg7Eos%Y?AW%ZLiy(nW}nD&zt@2JkG$FZ>#2X-?6|AxPR*Kz?({Y-JH@qK z74(nFJWM?1XWduZSIrjf%^AF&OtOy2i(MSuFz>JhjJM4l+=@)Q z%mZiR39M!w$4;FcgzySgg*Q$I<$3(sD~){dhKXhVF#`;|TpQNso2cq6^OQ|3Mn=6` z7a`%F;%r5Zz5LxOt|zvNWgGmUDdQ(K`eY8Hk;(700h_w4MX z@_il?)A1hw(_8F>)sa%u24i#7HHejRr>Z%X|234Pt#0(LRh0xr!{IXj)9y3y4S2+t z=R7fN`CKDS_~#`HTxN-l`!(Wrwfb3;SN}TOers-GEtMWJw2STXT<}gdtw0wW_;^Uv z28~}RqRPbI`nIRfToVa+7}6T1(&I+Z7*2_w{T9YQX}wqqzD3ZSp*amd4mG(jhy~!l2UrsuG03@fEO1s<|Y#%GJJ20 z@dh@rWI(WYAu+eYMNI^Sp@s`+hMr7J-QA|V1o_;&9)B8?T7S?k$L!P<4s;s`V9>7r z#&YZFL$$o`7WIS=<`q*kN@@)fYZIu4|F-ggvKz)l?+TS2d%L7)1DBoptmnmBv2|_U zg{a(P+tAc;BLSls8pbwCH?%B^k*0ZuGu`7CxO0Xj>;3idv(F%BLdrL&slG@6982c2 zxx5zZepdp>?N}1Fj+R|WD+r)04s!JbA|vzcn2ZW+^SC5{lxwwW#+1ydE3`Up8DV!s z7BayBE>w-(x9uzzL1`C?t}+(X1Xby)Q}a15LV&dQZuDmVqujtb+9BmK<#&D4%|n|c zzhh`ha0X8X_?EGLiPqPt$Evs|@F5Ow|qjzVFa-}Ik1z8ipwR;Y=v)}0< z;#i**NHaia9wmRjHQSni?)Ba8B-H*stGefTuCv#MP)Ex@c8vfvn>%QX8?{p!pmq71 z?FAl|u_vh+sPNTZGg|d%bbW2nI^nH@?Q7bgU04qUZx~|i?&zUSKXE%A{XSbce$`q? z-VR-0$Sqhk|5-*thf+%ebQ%Z)JT{cuH?R-teC!I)9$BT;=-G17hWPsI>AsKy^lGa~0?70!ekhl( zA#g-AF%K8Z=j#hkAomnW>%8Xfu{$*QFjP>-TmKSE#lh3FAS#!VJa74^qnO20k!GE^ zIk?*bE;q-ixMj)R&9sPy!j*y6Dy-o`$g8AQ{AzCN!amVsdI9@3p_pqfaq_^NnbnTd z;(6)Q@*!AfT$~N-z=Jl^cOCZAb`=g<@Ux{$PchP8iU)TqwXhy_F3B+_#7h=EtyyXK#=%!aMmE88srQU8emlc=Bj;FG)24(gcYQ07}CBeQZGk)^F`H*MT*9Rb>`fV_0N? zdDIG$aPgi}vBx%;?pf8F7q6nE6R*>L0M^=V6>%gv9XyB~kTBH)dnH6ArV-Dl_aq$P zM27}<$dp!oO=Cswt7}VhToe=@*^@txG7kG^h6_!y$1h!eTdOUA$&;(v0cjB*Uq?fz zy*xH0z{AdPUcm#c1h#o<#w2`K8EIWcQ+jbvP;8C8MRcO_E>~myNB0`Qh^6lkcqLBN}k09aGix8;82bLxf)u7~OOdeh_vPzD>)3obR$j42h{2Fp)#Ik6WD zRRQK=yM-Wcbb_&O=SGK|kqT}xTlz_0sO;Mpfli|ZK<5Q?h~HldE^aVfl6ZATEs0Vg zetEV1N=&NJqjcd%&_XMrb>2=fIy|8ldLzBah{HMULEhD{vC+CwScBoP%mTdqBbBdd z>BqWOvh>jfGn`~^M5d18(TdOK)}#&2e!1Lmd)y5b$7OaDq6m$DZj$x5Gh66@7@YD=bsu8sDa#$uF=eShm4j3` z=7shp-L&*pB#>Svdlm`t{gqcl`F zjgDkUV&q_GreJV2R{>y*IFTc0Pfo-Y;7)!b3Nnc0@aexKu)NW3awF z-&=R?uoRd4r)Q-%wp)}%O2beRmVU%~3UZQfRb6gd^p%vJs1x6w>cY*k2ccXZt#CWdq0)V$-~h@cbXM&kju?;q`=S*bEg|k z9Cy2~vurZ9)k1wy{|Q>-qnFfkNIi3ad&6C|_-1);Klx$f0j?pbi(Y>VhPry2H^PoK z)!}#vE+)2!@%wpYB2R%TTU)PZyZB_7GrcX8B^o5}AzmKE2; zkj{K`$;hj~^D$X$aBI?8m=C%v&^S#=%rqKoOs71nP#gmpJjGhpf4}UHJLCF}l7Oyg z%#mva_FPKb9bUivT4wo@Hs-&FfKqlZs3NHm!gDFcIDx4SKjeWPq z%>758Yc2F|jFJQ!o)>JlkJoJK`Vgyj5F#Gg#7YIyL_F?@Y7EUcXdTZ$|Gt&DIFFpv zWi~_wN zHg>+Qbxe&-tqz1`M7-&_J1wHLj%cTJ{?C#|!8(0r+*&lr#69k&{$7cPS=yImePSq~ z+HcWcrXA5TLaP_paCO`(eAn9)ZFqk!^5-oVx}&AAL^oAQsDW0Qy`((Npe7la`j?{a z&%Y|**?=ZpiO~*z#W@9-zFT<{D)Ia9@@DsV_4?F4+i>ifm*vwg@eTnMowd$B)mWT? zjZ8=;*0jU|IYJ)&QwrAPGkf68JAve6K8{caZr{c{{&yZYf3zPvs^+kmM`&9n;w8Y< z5s2RxK$3O-5Ba6~=#d`-PsxUmGbziedvUIOL~Ry3gaNSV(|^+eLC`bg#8HG_GJr9c zOl?FFNna5wNW~cqXghOMz(F{*nXNsAtP?(8yQeC z#)KaPbIr6Tl9R;{a$FoPj=y!Oauv@vK}C)cFqvo17Uaj_*N+<)YVqkuK>xr~xdjY7 zYB6LZ!>AT*qSU5jGjW3LXL;mKi16$V#913jd>E81@=0`ZzgyzYZJ!gQl)IeT-_F?4 zFg8#hk3(2^y2&3Ud%-b{`hv=$@m6jHVSX#v{^0HH^owtPc6b_??@4>@4B zex5T!Eh^V%<}dw|I~eflKxo1LG3@l>=GY8rGxl>E-RT-xYwspd7HiC+O5yD`H&-Fn z_3&S*-FXgF>lm7)7FhFVax8~Wa#T?vKcu#r4gk3yi^p{CL6G!4JY&=22;zjHw$lYG z^1{Ju&ZWsnOv`cZL?Zcg{{4ux4XZ@9PD70>!$28slUHSv1}0daH9NE~o}}}+hgj7z z-EAnroAT5OP(S2VX(2JAMgkk;Dd-^osj$jr=GD$0lMx6Sc_=F|CmN&^$3gq{X@z!j zv2%@uk)-bXRYQwPKid;H%7JkB!G14ABYoqyBN>KfyG~=v!(Fj!Ez~_n-8RcV)%V;V z6wQ?~wn@N<=0QU;WYkpHA;SHmxJJq#<%nQ+wjUdJ^Vn&tTUwq#5orF+!)n{S>3`oZ zJ92zwob9eGP4Shvq$3Q)Eq@a^0pFng&NYUS;vL+ncnnLg8|UN(yy5bg(9e)P|NB^ zj+}gw%zOxg7AkEYAF^9={kz-(XYEc!1F@0?;R?-_+08ZraUIaTFqKKlT}B+!_JMBt zDLZ{5P$dk96gAq#evKMaH8ff=M|(Q+$Z1U^0v8)M?Ppz!s)y(mD<3Dym;Q?2Q;lEy zEII(vQ@qKQK=vraro_!XmbKeYhML}4mbalX`BnCe8Gy+_Zzw9r<=}GU`?fI}75Ms% zK<>?-CPS_=Bq3gNiAH^@T3oO4fU!>li@%@+>8quR8biwm!yr2IJ!7HtnkHVC!uuY5 z4_E<-pz#<=zQ|05y-AS}8O!F6!e6g$r00fRllTQ_a^W=OKT-an1jjuSIL@U0;B9@w8 zJ^7C*{Lk?!Y~|hW-^Ulgd~yzC@9iH78iJgAg__2*;p3{7mt5Vx1tEL`G?dLI9P8pY z6)_>|jW-jH1d${=i-7Q<%qlW0*wBMb!WL>d#bJhyhRoTf``RQut~{^%H9H3R?=8qW z0QRlr9Hn#HX0&2nrQu3?Xb%}0T-lNEl8kW(-I-*xZk9Uez6Oi)37et3bMEN}|BsL* zRLQDJV}l9_4K)d9FQhM%)K#gP6}&fQI_w(SJlXr!_2Iq+sc6~}W+}bJ;w`^SCIODkvVs_oDkTu9R2hi=4W<)_})O?vY}T>bA@xm*zu{E?dS0|bc;&_BydXd`h1T~x&d#{epR(o(i?+subBUbUy=JEv_C&#p^nx+uKaF$bU?}cTg=?-Nis6T0of}O5CkTCaO$fD`pulc{;0iydU(fh zY|MJQb3H*PT@VaQCYY>u8DKw~hYn;ml50lGMYQnwCPHO{_!9|?$Q^!7tNOKgna_I} z25mv-etQSa^w%H3OyW;AzKxX1CD1^7w{PRK1|mkf1;|yxX;vb!Vu>??yg0ava*?s$ z5jM#ni`*Yn!;klRMe5l|{D)+u+Zx>uRU)|vUEULRJxdud7qK@Bm@QWAl+4TgH&dsm z#HCC{Arz;K6Lzb^C7Wd1=5?eRS+t#c?*%Bh=m!kTs_Aek3p`rARnWL0P~$(8Vb9=l zlkWCagBdPM+%uIoEMs<5yD@MOJ2)o+hp+XEw~mX!FjdqC?x0ZG@$*i% zWOs{Ejpk3DsfM359CI+fRKg2A@TjM|TQ$44adOH%MfQ@{m$7Ufm*bmylWK^EdTK;f zXm>6_wF%LJzswaWjaMoymz8lg4Vpz3sER)w5;YzCnmTXM4!ip~J%sts>z?daI}`6O zh!AM?KYHwKcF!MN8P)Dr4G4D{L%mj(Aa{~VkxKJ65e`4C9p81Fd`1G!CUXvcfa|BI z=c;WIVIwm8J8yQ2xB`vy7OFcV(ekGB!cZ~>qYP>-0uJ79-``((v@Z$tX~O-}9#HEr zO?9*fEc7P(=?_TdZSMfnBya>}X49*%)8|w;v@rOXxOde0dG@|3Yx%@^?YG@r3zHU8 z#jLk{8cMoT@A^dA+t@bXuBDbOsHv7YHm^2|G@-@(>@m^!8F(J`n#ucC3X%iR{AS~0?tkhA4eOn%};*vpAiI9Xcdjq z;-v}1w>~~kUSD&za43zdb0CBgkdf6N;+Un|LSKK8QqO)t>A&68@sCy(z3Kls|F0e5 zQ1_mmU5^fO-xSw!U3AZI|6bh`zLPg^%MSya^StiLFJ>=JYdGt&!gm6XozdNuoqAQ# z)uAJdQFNmPJw|34%~JoAbpWPKG_S$gQ1C7i@R(SL5911mX z7OZcTS+sX&ZWiqqBYRMAofTNFp2ESMtS}$)b)^-^@)g(@5LyQhq)8SD< zJZqnXn#88=TXPsr(-#;Ny0L10N~dO?yV={!nCLIc{qxa-nUHKpq#m^FC?p3S|(>R+p>**s!L{|rZjB#eJ37@ zgn4PgrzA4bhNa6lC(+^8~u!zm@bHqWg6 zQ}0X0f zWL|mT8Xd@$J-cfbI8ZgIX+r=WOf@ihFG!^Ksg z>ecdJir=O4VGHk&YUEn7WtJKz6U`rWVZU4N+v0C=brp$mzZkZuP;zZZlG;wV?I=se z-ms9#2>(4^z}Q3L9Uq0q4YIPWtjh$s8yqA>%?TUj70o()E}H-Pc`NbKL96#d;0!=3 z27qU0mPl`h`VgB>d*69RwsT+Ox9N>Fd$VRdORf%Z8T zL|>ial*{y(*T>IN*BDsd1%bp8Kpts=M($2u$iG3(Y_EV;#c8CXm1&A9BeZEX_FL`a z24yrV+#CGuP1A5`umHjst_bl@V5uC--_s?m&iRiIh+_Q>d|q&E$J z$p~ValQXsA1Y0DCey4BsVkEUF)(8;9));8R3BM)oM6DPNsC*o)A3sPAiaq%DVZl&J zImb-q+$7Dys3ScwIL$@3tQC|3g$gKh*yW2fmsv={0(C-g<3f9*cLg)81TI04MUR1$&Kly zOL}kF$rdb6c$? z_Vf@38!zKI6ERf=Bn z@j58?Ukb;wKYuBH{-tQ9By||Iv~aY!N0!p2`n7jb8cRNH8t176W(6x4K@*P?(*(0` zniwpll~}^W#u+6J_8DoNBbjRiPSMra(_};1gGi3!(9DZ>ZNcQw$ADmUa9B2pT7o{Wo$edLa zf#uV^DYCHa!0I7+NRE<2D;MNe>ElF7Y%mMB>NI zRFC!rX5z4Q`X+B5B5kqaOT3^;pKE(ou8uY4Q@Aex#p{GP5VqEvFeuQ5kAS=^jKPok zcKi`lCtd2|ajori?|VXt7rXSQQC}9|GN;nBiMp6e%b)%{2!ch)Vd5ZAwhUKoZ>E#A zZ(92%^y6Gyo$h=`Ke+R~(vyy$XNNkU9MA4VHm+Z%^KPL*b)R&7%n^CQor?K2UtVJaL*kV|ds!IF9ePICkPXJV1NO_g?{_Yht|E{Ni&GDE7e2CM@9?91a#Y41G@{`DDuLbEfw>St7J87rLdQ%EhHNUenj z=!T4Zx_5MMyCCc9q>WiV|9QQWF>fTYvWW`lapFSh)Mu`IRTDtvIy%!xIg`zlnLWsT zGHZ9%Ln$|K=h=x!o30IiV4z>1+Ll6XkCa$$6owr;&yW^~ z!T-E5VfsKs7oqm={HaUmfLDuIV~o2n+kRr&+2+}p3R5|Nxw)-XRDEqBZ-EU+4>Tu{ z$_)xiy(^wjP*94E=1RBSU47h@votJJQDc_iPig2cbvYh68-0%(liDF$l2Hw4$d0cLN*VA;F(fo51j$` z8<)>6lt4&tV${OB06TONcWyrGr*BMpZW7uQ1E1&kC_Q?e_UOU;53}S%UBYxd@zLox zmP(R@xf4~|BdtlnNrFKNzossW>r6?<4cLr(mQZllysyju;_fZL+S;~#ajFyww75fY zhvHUfaR?qfSaAXbiWjfpRwTF-2@oJq+@XTI1_@HU1S>7lVtwh}=g{u6_r33)`@iqK zbN=~0nz>fy8Y|5jW6Uv!{6?Ttbaw%-P6KrY;&ej@daU?ieril^vN0u-!z=&6aR1EA z%zQYNkV=GxWm>$|vvtyv%R|5ELrk>fto)?y;0Ic2P-Avc5Z3kP03CD$X=N?Y6zpZwL7eUSdO1|NRN-%o zinw1__LX2toXcOIxU^=S@ip#{ZPtxu7T9cwM!0WX`a-(a>_^lqwx%MV=pQm*6nFn_ zJ;nU%WU|%k3?@dfq>6^O0yf)y-AhTL2n|5B1CExqH|pr27KH{+Nax;~XoS*-VMBL- zK6|Mm_jq)?6uq5-9T~I*0ly^AtejHp()2{zV;QgUOEh9F#pVcG&)B+FpMP8KjT&Kr z3bER)drP8rMy%B7EifrL{DT1HIPPL>?iM0XUa)252{ol|*4lWc@pCTJP3jzFlM&WO zwjSeO2+@(w$R4}Zl(%25wX9LRAcj#Tq+q0g6nf z*1#&4GqIO-^0Ccjx%6kr;T#c7(Th6!+QN6TVU6#1Fpveea?v^gL9%tGF4!Irp+UD0 z^|3YbZl5Zexv?28dp=720dAlT? zdpxPFDaJjqj#-qB{j)!vf57kp9oLOJc^X2MfY)Q2HktzOwyNKk@%ECLY6v9GLsWSa zxf>UZOKg%xQQpRl&-*aYjYDS`OL1p6uw`!d^vU0t>Ei9*pyxI|t8ZDqeM_TUF5do_ zMf;q5svVW2M4W15L(5y32dCZ5%J-!O$27SmYY{4ixKLiik(ntkViGy<4ii61Os|T$ z@t!g#e^edPXIMzLl4ua;Sx1iiVxO)S-^a7G`KETMXtjgFlmn0)kWB8$)7XpBfF0+| z@s^Dv`~7SE1M0iPld?@yJqf7+GUPP*D_TN>h^nPhxKRDEp`u`Xe-^up7o`I%nmwYm z&pF4AFvK@C+C&tMZI@?DhG}WF6`nuMIQwWTY7FlFEW?M@aFx50uYkOB;f+fLWAaB% z+&$}^+RJt~8j+itTGXWYY{z9L#SQavxeyV4tpl!Ge9SgVw((r9A@#hEO{tzf5PN|< zLI)J&V)h<2i@K%Nb>I^nteXd6?<4BV1cSl4uq&SG;!7_KnKY*)aYR9;VHE@nT@=F+P*q2Z zq_<1>giW!C!^sml5;K(B^B9D4^B48z&7IbhoyN+}G4&iVlz7{>e7<^@~C& zyA2D%`l!e0JrIbdD<=yvn4vw@PgjIwz-NW(i23o|l2VO~DqQ7Ysff&ZGLgzxu5K!M zfdn0CX&5a1XANt!qiJ$iMv%;I8naueaPZ-4tFH_W+-^?qDfT*K$Y5T*38M&(K?o-Y zX_p9_zI9xWgyQ}u!{XPaB9}ihu{!Di-SNw21}dCN)2y+56lq${p?tLjwk*Ay%D55pKKaWuCiJctp~ZWMK-1 z@iTF-5b6Gx)jWuzFqks}S0f{x2OeT(v7Tx?1O)fu38B7){Vsw(J9k$Y|I*U?((fL` z(YoTC@u((-m2EDkm5w#`N3IDxHoaf2iyV;e#dEwE_|`{lkY3ykuE`79a(8RY zmWv9E5_grKji&6}R?6AtkgHaOneZ1p)&{F7MO)s8Q>N1)1{(m@d4(Hh+r?AO(JrieRR-eV{(D90|Zp7OiQ<`-1Qrjilw`J;jSIde7?FikJ~B5>o}-B^jn(= zB@ivH*?vx$AU2ThiDN0Sh^B(oBvzC2NKC$PK_*Q3a+xX4JdyGYC#q-Ged?>b`LayI zO}Gde4@@i`<=|W*VF6}rx-jprCj_k0snnVo!4_P-QJ%^AGw4hNFU(wHI!5>GWp!#& zhEV_7zVEsoMZ+4u(K-88?)xDfU;Bpq*1J7>Co-Y*t>a{w{EiNsecJq$b)qw}@+gxh z_#(AbTiO$~*)<5x3H^pV9HYVaYJ88j#HUr>)5&yI>w86HzL!!mn|i}l!gKj&T5`U5+x+Mts)re&3H(UyjExyUr72ak zq}cns?GX`H6QzW$_}&Bt-5yGV+o4@9DV!C109?HqM~hW+V?9Hgru5J=|5w5b=vQf( z8D0k6NKqniSc-cVoo7&UlcTG(P*%%!RD!O4Vq+52eRGV13{!QcA-3cMdu(6~7&g_6 zf`+J63aWVVb1}%ot)1pj`Dqwj52&>qoKo8GJC$~h{Yta@aULmi=)dCXCZ zK7^*fPU0R9>D<^7H@=k??60?@O+h<^Du>#A4#Ar_o z91%@6sj{-XeNBMW|)me&@)UgYSYb3ZCxOR^6 zZwX^_Sr}rw8TpDzQD`oCY zlStfg70{zku`KSH^R2J1Is9^~zYnjxSc%1gHFq|R=vqO3rV#dsGri$(;V;|Qqd=*f zaqE0Rwe7`U>42MZY%CjUU9GPJP8EwMeF$Ub`P{iCj&CxiSE-E5bQ984caAEV$pTF# z1^4vSkEPG*9H1N{kOv;(MDM!=`P(gBQ}FbKuLs|)=~(=^~S zB`BQ3h{92xpPI*5=;6IESiB6&Gyph5Ad7`T=1=}IhcP{Nc8{K51qB(L2OML{8FqHP zTQGb7lcM`shu^US_fN>0!-9DzYgES9JaoRDRIyrHXIabmM%grvIf z1}H<{Pg7gDhJ68;)z=7GF%e5xL!#|(z#Kb%G^OK4ID#-O{A$YFFS3iQq*NbQN@i09 zg+J)I$XI%^_T<+u`nNH2=0#74h$$6`m`~YM?$EXrCQ?jN>z`jZJj=_n6PiKin{pUu z=BZKJToZ;1a9wuC+5os?Eo}!!CkN-|<=$-|zk`MOax{@~S7&f}+DM?GJ>t*QFlSW6 zxH_|RP7KbS>=7>#o;8D4@7Dyabw*76c{cW2=wp7yHBlF>djX4|Y|QYK8=_I;w=O;1 zZ~7pZEKU0nm*jPt$ItPfDd2Gw-6!1*s*~9yi$dNlcy(&faz2M$3cB*rVuE0goW5g4g5wC4UNd-D76eeeUFkAYWXPdS6+ z9v=SD-Sj6KSL4LLcFRb=56U&xpI^K89qTUZK4-8NO90;~uFv^H^^bbEul{_&b;15D zuCVeV+Idf@J^xBi5xSvJX^aT7L#{E)e0%XKao?t?}-V<{wBg8{)!}wPz0MGC+iQ3IgWwmYD1nN=i(gi37 z!c^N^#QrHCCk2cBl;|Owt0WGYOW1Z-V`3@hj1kD0r`JL6%gFPk#o(Ru;ttjjjV@r6 zz)`!%c`y?Wnq0$BNe|QVEZ7q}CdJK5I6(~34zGiWyi7e@wx0q=o3+EZujD5pw-K4fXI ztHtE}o?t&Ge!^x4<5^XWGU?{=VghR)PZ^s?KBNw~7FMge7@f;Mm-iCy8}|eOoFRO9 zGkyQ$g&9-1m_;9>)z<-rPjI1iF{0?V#o|;h527n$wNs3$jEE!iI20+uVOP?s`0^@) zfKmlY-((B#sTVu-L@(ne&gXVl7qbxe*wrOLW+h*WDid=*W#44vMReqB<+2%EP5`M# zn$YNiEUQW(Ob0@!d)1S7oYJkCiSc#%(_(l)MD8;Ebyn7@IE^z&uSzZ7Oa z1*=3+KA|OAHuhEtn_p6o%vuhWzm^q936cvwO_eBh>9O$k`HEd#MOwS(p@E!^=KzE!s+3C7g;&F4dd9xaNX~(|J>UA z`>emc<8UM|%kB;s;q?j3*dIm;E+i@t9B zS+Hg!+M($C?XRr+-9IpErVRU1KX8BW_);)h%0{&s*Z--Zt_V#id*Q(8v zsJLWUn*HrT-w@=hArXH^`~Sl=SAW&wzpD2qf0*{$fj{NngMnT1^w;%1v}1C}S{{6& zpjU0ZBJRF8|0G8hr|{?a&jhgEr9I#K5e}lOuph9(b-2~xx3E3z+wn^LKgA;b10fcO z<|);+A#?zhw(cL|4bs0v4Sb6()ccb^T>aaD3UPMS3*s%&W0q%iMDpv)3|CL^&%+<$ z{s5r((@W(sYWYeW#*uaGcFmr-8M61Z`#aX=nK%~LgFnsvapKaiz#5kgFuhdY6lSko zoe$P3{)_om_j;CE!Tm?aZr9Z)h6Em$r@2d=XFQ?D)@F|{rq6^1~W9$D+ z;eP{m@DCR-7nn!iY!l%@@M|Jo>P17uPT!N5CV>eS%73EXa&hhdIPs95HlI}%`UZJ__PwdH6sxEvcs*m3BR^}qrbpvp`UYS&1+j2q z>A0&kGr3aO@_@4KSM?$Dm|EwyZv9AN`iK;AlfV>&9PbEP((wd;$HMFeigzg^8=qbK z!ZswrRd7iKE8VZ?j=6yU7t6+nJFlMl_k0%`LgLnr)7LMx19-Ww8 zJkftCZuk#aje&XnK2?3yHtDwptN05Re5XGEihs`0OVPdwlrpvuC4V=c*f`m|X)}k` znmI2H>c_Tw_%HNO|2dBN%~d1$!&MVT6MMm6L`~B9uaFK#6Lv-|uHGU%Fb??+AXBZNmy z|2tL)ruH2w0xk4?B`NX$LUa8Gy!ub++$+4+7}rzHndx)WkH!48Y0ZX(>vciP%vPnL z@NUXsA8oH(zj-T86c0j&v=Iu|gGug79t3q;wMaOC@DixmnA(eYE7H=M9hI^)Hy(7K5w}Hsb}*4W*z)%ZW8;gqiOT+ z9Zeh{9&P5QP_2F*PjMY?3Q1 z224I5C>1iO?NiFgH|P|`cXiP^^Mx(DL2Fm4p8R*Vy1!|O|7%;_|H7|bT)c*zYxEI7FCy67$CVa>B-fI$vv9I1SnhsHTcg-LSGU8A-ZxU_9P+lvuo_9 zd?_qUmg3(=D_H-^=Q~qlI4J5U)OKnmdLVP57;G4-5o1u0vr-^5hXldZ)N&Hy_>0$& z=BDPJVaL~&n7Xc1UWB^2`jK!h!rOD;4862Ua%*b}Z)5Nes?!1+buh8cVf|dY z3Hqn_{$CAB(ys6-TDtmnE>Dzt#@B2ysXKyd7#Bbjm#8<*w*rnQ+e}ZsV;#`D&t{k> zL5|CfN@Jwu>T101&5$K)8gk!Y2-}vs% z`SHowUg~G1C%}2DKYAptn;)@}uPoCqD*Iwxj4pL!Vbfn6{Y#&}`W~)aZ98l0=@ctq zY&K9m7J#rvlKCdS~{MmVmAJCnC0fdnx=^(uM!OWFy7_MMWlkl|2M^Y$XcNFbx_-R(mX% zbu%4(WgZK--{gh%&#k(6^p}Kw1vB|KTQ&YDKmW;>XPgYHwoS`+tc*K!#S$Zt_)$}`-Uz^$2(w3f%Z(?0vfpkyQUFfOES{)U>S>#q<>N>kPFLg0; z!}<@;M+w}Nak|U#enkMAIVFUEJy0=p3;laU3JiuBl zR5{f6dE*_d={uG<9{$9o?R!rF^q)%`{Q0z|5C!t1PRHbe?_b;2r!xRygQU*R5a{y9 zc4s9V@i)~3>`d2-J>0F;Di~>^h$wG-gNNm-X~X z7s+0P18?GYQa)FYF9NTcsTUFr7w9AzXIK!CBo%`+s6=sm<*FeKcmt__ z#shkD~Cj2E@ZO*czT+}l*BFQpSUdH%s72YlpoX~GX3>DlHHtMLiH7AR zrQ`%FJzHUuBsK62o=9*NbwY?GpM44Baid7<%JrgWOECzi>d}SbLeH*D*KQdTGp(-x&FEr#=2&7-Ho#6s{u_L?<#Jp`qqe!LJN*bK`w=D4~~DD z+H@C2_kD?mNnPw3Mbp$(P-yN6CEDHUeRzx2ZQ&oi zVxHY}1};walDV>ThI_uxb2_flqtYEa%%foTUtP>r9{v#-S|`YIh~^G%TAHCmKONPCAi zQvX$J%ue?p&zp7ysudlT3S_c@87pL=H(Cg6PttUw1}GR5f?SlwcDw zHqWG5Y3Pg|yg+$6&t|nhmZoP|LLj8jx~t@wKQK6&(a7!bMg6DE-v0v&Sc}xx-)w_R z-Po_tP{3gqvvLN3J*eVxJDfIwLx==Z0AwNpJU=g_ zHFT!wb#wG8pg*;m&*7fcE&7*kI?aJppE{lurka|pOxR4VDtTMOSHQ3iX-e1P44Yma zN1s@J5S%bZU>Spdw{C!vsHMp7G&-R`f5!q)Yw@=}e*2nfmflvif|@Z2M?RBKoj@ct ziWxs{D_YoEV}iA|cxqK8f3SO&uy%G))ykm!LsFth_UI`{(4VriPHEbu-0l`kOx=D5 z!O;%g)z3$^9Pb{r9Z-}jgT4&!G}=(c&=xcZs?B}5-ogw{E7ZI08k^{5Kw5@@Sr(fK zEy%Y~wsO9iGCLZ-5>nG#lJL@@y(ghTn5K?-(bHRW5ig{5W~YIOQnt|`0Ne;q(Ui!B z0YYxz- z&GM2z!^t(h$S&Ojjx4 zUK~%w``hfU-jLF#6MntzMDpLRP*&a>z!R5L6_4%}5&nI5&es=PWoPG3aMD4^LA| zw%OK+AMzNQ>!kVjY19OXWQKQsOQ0RdEUiZoYc;7k#VBVOBm^APqCrORtwszm!rKMF zlPy{jAfK-Crr{>7qb~etvO+oNt4PrCrR|^2$M(jEA$}>|jrZIRbdLZqZ70#F?DauN zcNr{lzPNqRvf%3pI62XRMXWmmA>TRg8)=^i9mE+h@Bt%)-YxSaoT$b@B**-lq|?L5 zAC56l0_38y43kxR(kkG>Y2xHbpq&YxM#PzAw6N+Y|Ks zTyOn3WW<(8sp8aNVN0cih6%*YX@KLEr#SfirFiTl63vo)((p}LLQzp>V1-c)%wGHI zLVzD~+?9p_PICortF%Fv?anIJU&`-uIi)%rMbk&cVxO9i6Zv`*MRUtux1hZ%E#uup zUh_bExx9tr?KBwAA+332Nyy4B6>xkR(8t|EU656!>Xtje7sb23>DnB}e`s#w*JFOt@$9#`_ec(txSI#wjHa@Qnc znmk>shAIxEpknq?X}p?F`&5r?#0Yn+Fz@bKnGj`>5+lW@m>iFuL~B4<ooS5?V4#qu|F5-~-A7W$^1SJp9X?Dmh|^bC)tGIiEX zb?y=UWOsknyGF6(b#>~>4|!Ivym-9s+W;E$?d-X!BMBXdB^PX*8UCEgF+flQF_piW zsK+s0m9tay9m}ECaKxRAaO@oV89KEqVNL2Npz$EkT87^1r6RvO%UX43Ixo)|F&y7( z2&U(D4<;`<0{(c%+1^Drv!(kp&2!s7dgz>gj|I@WmX3FNwpqoST2ykxmX7nIBq=>v z^foDaYM%5*1ZHWiOolS%acs&bPZ?)!Ce_^q&Ud91j1Npgbq(g#tp^L6?9E(fuEFlb zAHXR&ak9+YQ$5vWoLWeHg-HbDZu>BKjG$T7bu5!xG&x2uU zjU@zkC5JAQ_dn|jy2LWbxL52Tfn&q8)r>o}&^jZ+_~AR&*2Kx-o@CPr{J1Vs>zC9@ zj-ZRZ%zw#6>Cd5-`HYk@t{;z-2u~*-XqqP!@)mX7cS0PR2xw`j@sko?ziN2%M*f zscn^bG)S>7#PnQ((Jq-5D=sH6KfpZ|m!xK9+0yUpJ78mcwC8{ZN#)wQWcqBRELQxwws}G|0(x`BR=_v zXN_oy8t}gA#Ja9UwI49a+D9YSis0B`OkYI!bpuJID855R|I6C8Hlnu_2Jw|9Ps7oo z`0fdf+|DfU5Jo7}g0oC(M*f)wRHCLc^dt3Og$`iSh3neBJrjx{eEp!-B$1&g-=6r| z3`x-zVq6^m_-j-Rl2;l=+bcXE+j?8ptF3_ELAb-!cwsmSvuUpY-b~F%_D=chSlz^P zh?U};VQT@6`&zg)B?}#tpOrnPO;p%oLs#2I!`oR8{3Xx!tLcDS;c3!$EXxA}9`0f} z7H*rj>ESiP!5nJmD%HqN^`@5rIU;or@jlKEMIE>ydwuisMUX`w8ftFJIu3bwKPM{s zlE~kumBZX6ew9=O4R>RNwkhg!IclIMsqvo%^mP1)2A|tdo>T1$B_??b-wUUf z{kVF~Gn@}tD`}m%hLqE2+)OWbcWaIqJ@Celz`#lx@up{Txi+GbdtPn1kxr87<&*n% z6@-Mch?1$d<*L`w6dcd!H6eKCkU9YzfG1TyZC|CqH01PoPc{kIXH)6}tH~i0=rtiW zJ-r=V^Pw;ptb`F2HX zChEP!(=@p+WoD`ME43f96f|Z|A~=}+_^n>#gzKamTeypO)z!K8Nkw-*r|S#NuSU+l zxu4fSOD$*5ccG&wbhJ~AuWT6OSeweMd2G9G*+r)KNwK;sQC7?ak@>Df^FRg=VL*x; z$GkCAq$8JQ&{i1#+qa^oaOLD!UB&VMje-_W#cXZyDx3LI>s#(hPTWJ{`Glidt43F| z=~eEo^x#niMU402Y2_H7K1?7ptK@Nek375_cMu(2e1i7@`LwCltSA^#RC^D7TqBTV zR@6)umSzf=jIKWeETYtSXPnD}5D4jGeGH(QDwpX#g8lOTs7TKKGNPZ5cwGOQvRtcV za@Kw@z1@<6(gKP$udFwfE_FVQgaiUCf6zi%`zcHdfm=OU2zqK+?VxI8N4?@XFPS3YbUb-2@SCm&BN3*&M9(o^=&G%o*)>f>jwzt=ebk&KG?P-hwUi9(>WA*OCMhmL({Z^wcLc`iQNvg%GVnP|s3VHawxGgF`<8N92r%;`*fG;t8)@U-`VMMT;J zDDh;(d@8|3brL&Y%eV4|i$Svnqjgs2p7hqQtJcG=mjxzqf)G_*p3#j>6G#oo zJB|5>C8F6l3|g0DWU>4ttEQV9X_B5!g0WbW9K(|dK?v`dlsT9lTo(Mv48L1mo?oco zeiW+@u8@EsD;(2?F8{>Kn<<5v1vmnCy!e^l1s7SrrxYxqR*oTALhpyM5OF? z5sxebcT?t`+NISRSS_*ZMxXx6jD&lJsS_l6Zyhj=y(NjQm~+)st`{19?4Fsg!L5;* z7mKf6T%-f6OSS|&oa|G`)ZO77=XHI$cyGBt^pwpcPtfwY+`21Xn3=YU!H((;k^*bfe*Pl2Xf3qV(NJ@nom-k*rOgG8+aa|m1n?WHzn(^ogT0dK zp!6|?&*bf3&KmH6-gHw>(isv9>;R3Wuc#H1+*nDnC$ zxp9fLO-8__gq>NT>+p&0gI$Aio z`}@1f_V@PBS4_{%jetDxqtziB{rVqPJ~SA;fGGy!0yT6iKnbcEsp5(>K1rlDlR6L7 zA36l-nrai&fvUmzc{F)cie2TsrYa`&HE=7tyRQ*>I)iy`2k>H}-Yt=ckCBveB<2YEoAV4Qpv}xxIIA;2Up|!uC5QCtrm*uSm{g0wbj;$wHRh0 zSKX+ruO}5ULqcE9qQ4Nx+LY|_c7?1Z?eO=P9d#M|-kMg;Q_t2m?Ddz4Savb8$tm8C zZ46Eo5RqD=B#zj(WeU`dQ7a9`wNNW-r?FJcee|Ak%8wR|Gx_pkV?;)hm-7GD(mwE| z>LJIn3L)~^?QMo{oZ3vI%~wl>_0s$wxLvIVFdmsbf{s z<7%GZOcc2N;@S3`@$Qed$p;Hs^VBSwxs;}bSk|Y3hcT+xyPZZhswHHfq36SQ1lf3C z8*u}|lR(r*AaXT)+}I*%#=>7B$tH;sf&>=!iW^f~uya3Wv)LForndT+%ayEb^Mx&u z8&5ra17xUo%j|0&`tj||>EMkbNSJ>B7V|y)Xa}9lsNPKD^bXeTNiy^HbJ`pG|QXLT$O@onc%%YK7 zvSs|W;X}CO8ootit%yZOH@bAU6*<25D7vBO#-{d3DsM}AokGCt&g77Td?pVEV_m=q zf;pK)r3@IZa+BBri^&hFzAi{>)^It)YO#iqtSEXR#^J!s$C=XH zqmZVE;+V<=ai~sTCA7W@QWyLdmRRuR&1Kth{VJi7{KP1S1D344GTPEq8Srv9tAJ3^ z0^L`;gPKjM<#x}$&&d2xWN^d1m$O!?`X|nAp!hySV~gc>2+A}<#90ox9EQJTbl>2! zR|GXl7pcyqn&BLEI1RI4X^k&&pH}d-`~`=p)4Na{Yoe3lv`sa;*es_P(SaVl{ts}W4IcfL2_;0dejec6bgA4#G(wB~e&|V|$jQMW zp0eZHNDY@6(pRN9ae~~&+!`z5xQ5d=TXfU!R8~v1l*OCIR?<;=@JzR{TXkm^lJyX4 z)5_fuuYgkNz-dBFo=Uu=Zp0dIvrE?9rs#K{BFOYC49=)T!cWL} zJaoN|*<_JtT(a>b=(W;?6A2O99zb2N!{e)k4uKGDYg7BY6`+;>KUGtbg}7t3H_FU*?!*qJ1>Ql^#rP^a?!;R2$c>!vO8!$ zk#swt+iA9qMh$*KI+TCft+QY3i#X6BwTUT&kHfAhgPIG306<-YVg-cj+uqJ{I%IWL zC@xGFx9CX4S$}-)T(@5`$uGs=D3(9yNM!=7W0(HIeJ2=Am;sQBY0e!Kc^9GWV@v6y zKxI>F44N`Z1}05v&%@RgB~r&oP$sv0Bi@R$;my{Us$Ys`@2)VQ=!mytUoKEIiOlp>n>L zCqdlgS?C;{A;@`HB5CeM|ED#YUfW=P(F)rmZHN|%gs_!%EKO4LR zptP-fw2R1usd7)YJ#0g(m9=Nx-O(%nk?KdzE?z3W*t+bq!|6WPslwlK+$5r~)Vs#1 zX0wqv1s0vp)V^B8cv6b!U}w&YL=+g=AWyO}c$?;DK`Xym8^8T?S>>&n(C6%#wl7vn zrf1r=wmPA;K)d{bzT)|VcnYL$Y>p)xMD)Hc*-Nt0?MXt;WF>W3 zKh`6%4vzN>KcVFJWX5!-SsbpaS_e92=nr@}X5Q6_&#D)_Zd<)unlu+M(SWDJ_u5N= zE1*_?6K)}t^nR^OWDZgb{9<|w1_n*Pqn+Ja=v3!7O_vbrEV~gQ)uIvqX-|OpI%!8d zT=zXym1M6@nrRJe_L_#Kvlq2-2{?JUue{Ck$h1k<3tzcQSDl{;&rC!)p>J*cySc|h=JVr#JH>LE&YOQ1H?a!$WEw-nR#_v!GQr@e2q zg*+)K#BaYo-_Tf`#B*YIcfn>etnfS5g4r2YQ;z&v*tlM| zoTMQUnm?Msdj%iKm|*WUt_lgg6T$BlDHI)@#9FK{tSTS+dkSwGWgg8GKs8S(4v)taG=BPN)JVyd!?t{9nZVJqTFkE+@Iyn zJyCFr_Ss3UeC8q(B`+Yw2CyRVj)F|InHyYF14nRZgb`-n;pvRwz(9G1Lb3+G?LtBl zSSYIaR#{D{92f#=gOxBCmv`zz4$1Zfy?mE8dMVXxICk*L+(P`(3%n-J>@&s;(w<&X zr=N(zVz15yVhs}Qs{H+N942;;Vt^J0=>yL<6ImY z>(3WpqQfohGPf4zj&;ik`bvxuFmL_p${|dzVGXi_W%;dTRb5Y$ig#bD?pO5I-wZ_W zE}CdW?;RIY0)S8iY#M}mx6lfOB^v~YnSPjW|C_YRgV58P1(4kOo^lEY0Pp9uc!7`b zLt>ur`|xP>heMI^pr`H`K7*r4wX~DnhzfzV24cZ4xPvT=!91*`vQGi#18k;-5&@N) zW0X80!nrhfK8%gxL=(>X$ z*40C92ZaUcS0V=xp~(XE_>B_VtV~;qT9|}b1-E-l7m21|M_7EB*1S~|bZ-&C%!JFN zEg!+{+G1r&ljl%m!Mt*rBp-g{n2I~PmUEzopst>O2JkW&95s5MryF1abE<%?VwSE+ejsN-|Tz360eQn#1+k1R~Kr~yurGwE?|YtWkA5Mlmg zrqR?jt~X!Q;2~-+Gg7Z>q$v{Y1R zN4|#fz|MX~;c}yZ!?`z}0b_{}sRI}oQgByvEmCvbff_O@%2H3o+>P-oS7{~rolObcwDmQ|=F1aX^mLeHgvm=cu|tyQW@8y{>K z^bkKEj~kSKy?Se!jd`k7F^_1G9ku+ncU~`tMIlV*9+yy)%}o-q+x@0aKVVMEuIQAR+o6HH5C7bHnl>0=`**_hD*D=D!UHyQ~pYo zs&Pu;Uan0xv-G8NA%4O_hgp=*YWNDTE9(Fm4Vnm}3wWj-xmJ``ii*{TMnB)F?Y0#F z8^b#0Y)B-S05g+veJ;)n;V%-4;;+Ng0-kP4Do`5;30LJb=~P+iEoF>^%vTC=QT69r zFC~#u`hVQf?{^J3Oy7a1a6$CKGk|ryB(0T6sUt47+#C$*M>7@+IXl)_1s#t=Iadyn zjU3cmqSuKh45$IRB|37;1o6Z>0?GPG$X=2;PZvy&Q2n`+O#nWbh@pC3+8R1S(76ny z0W7DM>mRrEijlgXXr&&`zeMKvs7%~DkR<@A{i=Nrp5f!!LzZF+(k(0`*mQP593FAh z-iHi^e2{P)S4`rs)hlouXo$US-K?x$4~!O6cDiJO0Khc25351b^+=g=>#Me&CNOtjHkRs*bFO`Xl*I%v*g1 zw8pXA$y?tNCWcIkwAYMWD2k7*t_c-K0LQj9Tx;i4Z(>?bT;IO}3aD>-WaEpMs3=qe z(`P@e)A_un%lFy4hZq1Cl;*RHqjFGA@Uw>CR`0)D@!^M2tjtesOyWCjlh~WIq#no1 z<0LrQ2_;oLuo&%`%{0|g%+ao!b_#a6oP3Z@;)*ctYiz}z#PkHtyv&0A$kDU9>3L(F zvBuXCdJAbbw;6Dam3F#ZRW30-bp=VO4* zB*s#BohV zqT=X=O{T-E{SloHHcetVubWU-eZ(9Jawj3vGPO7-VBU1METO^hyn(dL_t%s%=aUtO zCvwu+mnTK3spB<9DyCh-jp!-ocGL!hoF3RjP3WWBcek_2{G_tj+?G{CPnA?$UiW^$0W5PGcFaz zP%=lGnUO>#E3$?hXQr!gTeTxfWwa!al7S_fVj9gX{vE$j_!YWXIA$}E3)}~1($%u9DM4czsf~1)EwD+_r(}xUh|`-O&va#o&tU*JD^MtIXmo1oV7-A zpPVZmDo{GYa%&)--`IV^C`<5vG4@_zO?~UyxBgWuhz03QdN0yDN{56RdO{IMfB*q0 z0ckd*cL<@Ql+Z&7N$9Bbjs^%N^eSDDq9Cr1wcmGNYwznl`R2i#Ihe`8%)G{U#(3`g zcgJywiivHBirK(Nk~Ke$x|8~fB|8QhyZOq3%^n1s+sGdmJtSx5|7-z%)F^IV7xH& zcI5fDO8IjiE@iVzzrAXHINgoO&zC&ZKe0_SIOI1-s_&ZUH_6~LrL78z^#L8=@f~4vn2OLrZT*A|L1zq)ZAJUg4fy8 zks8Og|D>ojW$yTJ-L@$i%`cQCOu#$%; zjw-V1@|I)bs!5=FxjSsIajZxOK}GMKhjDOmI_#3C4=Ix;u<#?F9iQqp=sx?lDhCe6 zEe0DpH&zNE4VwH)Dh>-%%@zQGr?Y0+fN6wK$6^k!;cuRh;m&9O3}W21UA>{&c$w{b zO)0TA%FpKcjhK~gv*q*kNX^FiWn1so*5|C5O)aRfaxE!gfj!zUK)G7h~dffBd!m*?oiqV}8(wwZK7B@glC4XsY*=$9P+(X#VtjtT+p1^|V7bMUC=muwDr8R^xyYOg0S(F?F`bpREAg5#| zYy$dLbD5TXHWjxnI3^OJ-yp?ZnV#`jHMYv5$_xEMAyDZtgO5Qr(Wlo`(sJG>q|nho zI##APimUN8T`%)T>rQ4JT7t|+PQ2SA{%2FEGu0s>HgGr){9`37s-M#cE-GGx%#xC-H%%RTd z7k{OQMzAf&ZV6a1rEe>YK4d5m@oV~KYm>=wDe{2EV)+cYUck_%%8J`#wBNrkalH() z*L)34lOmkV-F#-rzXh=@ZHdgDw7a@0d$iz9)xesqI;J>nM>#(cRSHDF14AMl|A_xR z0~JUebL|AAlwLUL%g=QphpcxK@pJa~Z@i;hyLb1hm&8XH6rn-BNxm%*e>s2_$j**-75OY)RE=dQ@>vuQ=Z~xIuBDi&0ERRc}!RDlSn) z>Grg7L0i#Td(!$O4VC-dKfKd71H!rb%gkIo^CGL$#6$H3Oc_iZnJ(&l;WSvN+$U?B z>K?QQ!iv>NM4aHBo!AR$M$eUGvAk&Z$s~xBt|skQdOPstHm$Y}rr>xA$Exr}8I|TW zEFUG;U~4uSBdR^N>gy07_0odNUnxl%&ff%mN2Vf}%k55tG_z!CIUu0*amCbF z+|SWR=G~dg?H*`8FDn6W;gSbmzvgKKFmM~0YbtB7S_rMcF6O@9v zB0mG2x1ANdnfrXrePsngjm4TSzVw*%Egm{eV$7~H$ZO7l@^i;MoZSQ|S|zBaAa~89 zh_Oh)z%L5dsFwmax7R@--I+a%{{-}rz*)NeMeo6C5qahj9b@T@LV}ahB>Rszi^e7_ zmufd{-EhBMWVPw|2Xpl*stYmC#r|({r@{jLEFWwC+`OY@+5j%8qJ=g%q~{HZHk%AI z@bp@?*4dW~RuPoPE1tsB+sA$tDW#4Xo6BGB1ehqj?^M{VfA%EfrOEA%nU`IK@EQ4R_zyj1|fHOI&Yi|Q}e7fvLk7TK-) z3$nYXH;ae4HioPehcema>c$KXJ-R5&11`0)_isBMig!9$+ZG(kExv8?Wp%8>>x1YS>Ro6Vni}b5FaU*J3pt!j_DRD(+i=JJ~L7P!3g^Q2HZv?0({VuKXwK;VuFPLw7 z4dz*vsqEvg#%1DO0l1KWy@hm-*^(V|_WiTfWK2^^kA3yNp-?&}xmNvs~Gg z+V1qAk<%W0HMR7X%N^cBcUrH^s6%I6IA8~i^COKtG9~9)z{3c{ZR6%KYODkXZhzyY z`M#C?y`n?)u1JR}^Eizq8Bc5#wlO?4dke5~ z^W;u0%OADXcyorbd5C5HOv{QSk41}?cyf}o&v!5)h&#Bh2`pl94})4H+kn^@$*R$JQ^N6Kb1Ak!kk5AAR-{I zfO)3tT#sLirmF;7^@-hW0`O}6&}6_lV#~l5-*gi3-9@3^8HM*kp3O|SGs>>px{RF7 z9Q!u;Wk*-bc9QO9zfXlnFF$EjnPeG|wqm@=)P3}nrwvV*P^R-aOH_Tfl*i~|=e*5} z6}_}g{Xmmllui$CjTr4dxRI^k-zU1gUw)oxW4Cb2D?8vde^-KAdk zA=Ph}>-`2kae9;$|V8zg#K&cv#*Y;Hc{D{!4oPBX~Y*(hBcvY@r)Y7 zP4}Oa8*OABcwYSL%3Y`pH}uv_Ty~@GG>=q(enA4FxYSdlX<1erZg2we^^d-nBBxTN zF+URlg7WIlfkwW*bjkOB_q+JzShK~YGjCU0x&mexcF&ZiW3@TevjcRDh_sB}7Hew% zCF&zpDb_SjxNOu1TFrhtnad&GgCEGfIkBuA_8TiQl71?n2#_^S&E*FIl0LrEULAT! z?j!KV%?rnpgHsX zZ#}0jKj|NDY_6@Yqo15cp5%oJt=pL)#4&Teko!w^NPP2P{izvglQVGoX0h{XXjm^s zKa*u<#HW72uDAu^ea)PP%Z(9I=6~vR{UC1s*td9iZIt+P&-?Tq;TqiDzxbnL8^;^^ zTv9dt)Sikh8wv$B;pP3E^!D+x=D9W=KWYH)EiP4iq}t43H@LPRoHHF* z_Rq&*8<4tCN*=q%0BJ`gbrGMXZ2J2zQ=-urayN~MIf-$;pm|cBJWb`Hfk^x!NC(w5H0Bnd{2Ne1WS_ ztjsEo-fAAqQY?l;FF3vHthFx%dVqV1@ZGp2j{F5EO>EWBh*wiq{-uf3bi-Bg?oT09 zo4;t|aevHvZZE}a)RoOyQrhsjTz5RamlohJuH2O1dWOz5jmta<78)Efahth|RT@;J zl~?GyAx|G#cabXE)G)hpr#G$4oivsGsA7dARV=A&5oZiq@deJBms(6nlHV?pQvz@X~@6v@5GlrS~H{>9MdK!6@WvdG98)P2bCy~UHD5+ENYpBotQn# zX@15hg3|~2UXTz48%$G?&sOJpjYecUi3kF#1MByp4Pt%LRiLdb}zKoKhY|(bg{lSV4n~2ztkU2-2akTyYYmB>JzEyv9h$s8Y zWMvHVW6*n*IfdwD%AD$0&p~SwC?_{A+ZcLUVQH_^zbdZMOIv1BQFu@~V6mngkKG~m zMn2yztr+PS=<~G%8H;_`nD~2kVRW8Aj(b>z&_r74J_1j8C6vOuy=y$xUhmk{Pp2-Je-RCtIe0OwdK=7jx&sq?L}+eH#Cfe*`25!5;X{4*SAH55~!S z`drC9XNd88(_y77j9(M8J}SNW95`Jg_RNYK-+OqyT`4KnHCo$#+PTiEp*4)901Fab z{NpiUfRlj557TVkN%+*>lTW8-{>19G+x_z8p|c?$wEKel5kW^|?G1!_(M8bqYCaN;U8#iBIJjENbER-4Ny^*Nzr5Qt-U?@VEtTauSC*AU z+6Zi|uJ%Y=6tNJ}D;P2V*A<>jUi5lZ><)kjOK2S{MwIakYYPHQ2Wiy6g)yK4F)T33 zH4L=9i&f^AroFQOOHW(1+NC=>Z|_ozbgZV$Ut%h!f9 z5Ob-lbk(=%8CMH9PumBV)~l6AZk{cc3jL^MZ2NWtZ5->mqEH6KuA6)~2!51pF(4ab4D~C{vD<~=($(>lZaE*xiG?IcdD6E z`6(>$B=`TenEw0nzp3>9o1y*R_xz7*Pf%KS=vdbN(dyky$$aR6gUou!CI4RS-iJ^t z$-mRDvG&p?9nE0}w!P=Dzl4{=&;G}E|L@2DAe##_|3j<)%6#}A&{Fu)|3EbVzrS$( zKdRl!;G6%*dGGvp^b8d`yPR^r;hJV};c!)!E+EAw@PI^r_y7AwxQ zbbqjMbFEF9)JsL1gH}x!0iXOxaKZ>#;dJRiSJODK}5lnCi5tHx$Zb-=dK-z@l%BCi_ePO)VNX6P1C!y#>$z^*8QoD zb=?nbrVk%E-}B8D8!_#bO0bctAfk`f>PJ~88?3+46L15#MyV8!x~pAhWrp7_iouLo zAW9?D>7|_rvSk-Dp5U5ghP`aPed@&6RelP5HBX<@LaDUd>4|BxOm|B%&lYlU%$Rbt zF0n$2z@F+g^Yld-$q}<}ouEkv}Z?i*s4d zVwK0skve3bv;c9+U$lYs_F}Gw65~df@%=7Zcjr-OgfE}c`z;CH?SE`96+8Hw>MZ(S zM!sGun7ErGL;J9$cFd&VK5JfD#jy0+Re15}4P^$5no5%V2cI^0GI~n0LR%lTaLefE zFD01!ll<*=>V7}b*U7SBH^&C&g-Ln77AZ{Ul z($fX|)oCX5CGtf64AHL^Lu`1))qR4_*0`ImtK)Orpg8T+y79mkC>TytR?PnK82IzZ-f04dM(o1Y9g^h`ul&!TO{rc6O~rc z!JbLqTK7D-T$6MQ8|jrjW>Yo3M`R_WBylvib-OoB%zZxUW6hyJiq?(?2(kKHLR#cf zW(3g=>^@rGBK%o;-~2#wNP(Ek# z@%vp>Mfq%W`TjBxKLg4O+BqNN_^MiyG9Ik+-kqh+P;xUSTg{`@$x$HKtLMl1G%D8U z*A$jPDYm%AQz$(CZ98B+!uA~{=^0YOcrQJLSk{N@oz^TR?s6RK+*N|Qr;BEO?Ai-D zxusR!-x`^o3jR1&H8HfoI+L1TiyjbGi!A4BpQsG(S4H!;1+P#ynJ%5N|Nn@LxrYsi z+I>kP4&UZ~9OK%RCA53`gpJ_D`*K-o_T0Ky8|$t!DU_Ri8aQ>4?!o2W&KudoI=>2Z zOF?eVh!^mhTER7e)s-i-$vtA!t4?v^-J9$`hxmNR6njOmF5OGQL2-JE$bL7QTpoZY$WTGIH&^3CFtfW4^*d%ff(pzRZ5mc9N3{~KU&|(TNRGv@pxp_vz9AhJGa z>7UOU5UG{m?AY%>BZIR}bki{5ksc^Re`Wh{#g4ZXc$Bbv!Z|7&XbR5X6;9}2EArW7 zjg8I3Xm)}rXki}<&0MnGMx>aBQLO^2xD3``48i!MH5qiAnT&j;F?SdMXTF+ShIdToi8(1a zHgt8`d6U~j#rfs;Lj(5Vx2{YW+-wLY^StONQMj>U0i5|wcChaQWiBlpgk2nFpcviX zWVZXYi4Y^ADF6rpj}arz9}5AzE+yt>^BAFhvo^!=*?+=9NG=)0Sya^N3;z2N?7m)- zAdR6AHAwtYFOX{>g*4ER|M|C{F7yEt*%4Om)|j?isX@}WohDb_zKr|x%2na%x7`S= zsk)3U{Ex=}&iMbCMT09dxIMM%`um+MPu7nr7$xkR)b$^7X(d;Vn%6lM?TfzeH+x_c zC1~PCXXafJl|>Yo~M@tc$!3~+3q8}%z_w& zjPR>2bYV+pfGgk9WRXVT;R)*9U*4`9X_nnEi3wR&=|gm>*fSSdXja%AF|z!X=Sihe zJ!D$=rl->6xT08D4m@l<3ucTf5AdH>cWH+gP4&0vCW&5XRHFJBFa%Sde_*%kItE#d zqf2trukl9rgK)-e%TWRVyTsefTSKXuh*WO&IJ~mJe}xRLD)941=u4>?X6kZ2VG3~^ zm5PaPT6%OB8Bv>4g**20sup;Vz-7hasNauS2OZxr;uB(E`FI%nDb7*lXQV9GHT|?& zIipBe*+0EEsZwDtT*r-vD(g8t8It-W;elD)zS_v~<^f@VF|#B3MO7KRzxr-(*2&qfL`lvI$XKkQ zz;k+#S63^K)UjzmpNcjdVZ&%@eA~19x}vfnBTGU!n}7wxkkCZp9dva{3QDV1_D{$Q ze%&1m9lLJzz;UX>x=T&v`>s6=8s$;B-9+RT>M<1X#TO<9r?%SW7TM;GLlBc-n_H=L z33V*jg^;^j4l;+M55K-DlNGM}=QVOViMw&-Wa5<8CiZskuM&l+0!6s_oRO?pwVzOY z$)wDCQpbep+jNb%(Ke+JTF;*)H4H+(cNc#3U;Xge2i~>Jp5R1l5T1;=`=n7tHp@TK z;!>Vk?D9NGlK9X!lIE$@3qpUB)Gq7A?-9N}>(*a*1&89Yf+D@Q4CjS-7# zPo4+5aaXcCe~lSr#TV4wxLY9e;zgWwbw4eC{k`V*sC7&&UVs&V2ITBv#;+Q`xpHNR zSvG9gn!=?72_xH5ZlV<+VIOvF%sr_`F(;>+M)a)~B8JDMm$c2XfHUMA;gj*%*0@Vn z_EPiJ(VNe!@RKRbt02BIm(;s%`_kdXu3>uBqN6E1Lgp>K7BfeLl=;<@`Kh;S%JA=# zEf{f%X`}lbkIPn-%p4m^n|%WIr|7Cl-%iZt7eMRPvO~L~-i9*3Dl*CFC}S19yszby zNN=c^HG^tMELX0Col-%zo|;TS8|?Rx#9Sx6^;{DhFe$y6!e;vu94>~LzYqcwGCDBI z6U`J4hZDGZ%W2zw(!c`DT-SX>*LPT9!$szuBXd=3=C=T3NDOe1=>*%yj2iar-J0Xa zbz^<0#-S{mmyuH~1=Z}n2f!hre!{@&Fp>TYv042zbGbRRBL%Ao$PrNd7RA^B#;vyH z4~lxEtjb6p31PhYg~9x!afZ72ZXMfNA`zFd070Y36sI`T2W(N&Yg7FHbWJ~&gH8rZ zuKcmNn{2eU7fNlUsLbnwKjn+yGBl%Z3Mm}CwJnDxRc}Yh%id-kyBt*jxLvEGoCy={ zpJ*(1qqoTBH-5g3-#YHG)HnLdN&&pIFVk}0_7S6l|3s7hxT#=b_eO+GN|GyKmhp{m zJ?=X6^gWc%haZ<((C08Z(`1!AW0{!Ev;64yO1Wo58wCTXeXqZIf7)hzzyX11FRu>h zts_rFOd}Nz+1+!uB>=uT9u++;{!h}3TLc44wh(VCPRd;jOUzyjNSPDAMu;0_*}1co zzNaCWGSK-VXJaWW(33QT1c<`S0gonnw#Pzdge!ipg>*Ti48)thkpNUrFo& z5^=Mk{x)+<-Ca{zv@1?Vchfv($E!3pc%ET^Qi&tA-~n<;3D($|%aQ>Qt{hI{aOW{S zvmPS`8wCm99n5rQ8?|jrydefS=Zck+NRfAd`(q;VNq;=zoC@Rq4q=4ui4V*nP>t}q z@wOM$Gs@NBLZDyl6>G;>PfSar)rQ_$_jm;fHS0h%_H=4LaBu-&hI|$fH=(g@IBI+* z9k)qjS_{5iQq3{X^kKZ%RD;abnXGIxTdKba6l*2H#R7w3yN3$Wc}oQk%bi%=kz%H> zbn=(s51zp+U;i*K9YjcZ9+w5Uu+?PR8yUh9znirdRL0+DxA$fW>neQPGe^zm#64Ut zo1p}-6~l;Hn?ko28uV3>rVUZLEMVUn2d(6^lDp2oGb)K zho)C|pMY{7nMf5A(JwZ`ychl%6vGXA;wYZD0nnf*aR!1wLyTyM6ofLF^6vuXvUCCE zlmkGhxo7{n^6bk0c4f6*kNL@Rf8Ma={Y`?E&6JfE_=RgfGr<5If0&!a9IX_|ZpZwsv+}Bw{bv9$--+Y`>qeK?tz8w#As>W{>a}6}>z1%iCs8ug<~RPGSFR*U zHd-(g{O)0WOw#dqgjgFKzRRRqm)`{)9^QmY;TdCxrJ=DCws{?2x(Ksyozjs**w?Y>rwl zj$>Mh`gGd(E`uQh$x~^Y`#NvlfmD0Gq4JoynjhRxd5kb0>gf4On07uRmqd~$ysOQH z6Ym3(i}-}2VB^9R{lZ=zK$}ywFa@}hB!z|mA7ELjPnzHzq5;nJ=vN3D7 zoQt-Ynr*ZDMwz?@H=S>m8FD(rkLvRTnK!P_6q;}N2WuoM3#ustR7+eGP*VR`pZl=u zr`MTjiJcumXCARV&(iYkR3`PO{19yU2~pz+>>Kzhr1?q{!odA>x)}GU8jP79W>qP` z$(?d8sy5dN+m>&g=2NbCAnKh#Aqc6SuPW1zCjPScM8hA(bP9YF{H@0C)@$%$ZFn!h zA(6B!l;Q?~+L-dh$m-;7Sa|&;W8$e9Rp$4y91TzHaC(3Lw0vq=IQ`d^PnVX6$xnw# ziM4eiNaFXM)gXR)*y9cI=#DRd490`}6{7hE+!qz8EIleXgo6`}^6`kTXgH%TH3Zgg=I}VBLKF4l8g|VAd=NLwH2gfF zxUn)(xB?ctLu{}KWU)UxOB%X66FPQ+7{Of|F!~lPX@1Hh+Bi4qa}=$^ws1>JABhz% zFnfoBacU%vJRD>YQywN3U<}*z&Fixe?UZYY$JcNa7)MfjH5*%2WlE%^|Oi(0?dnKddyn>pgVDk z4O#Pn4Jch#ai{wwHV<)x4ZfJ|T=Ua^8)(;z1|3~@xGQ%CL7p;CgDtlK7_|9JJ%WFJ z!AeT>s>FY4xL7P`?FHb9T7y zBeAy}X3n2rdB*m6Of@Uywt&%&;Z36JsuFZ0y9R|>7OQ-x(tHs&2m97@+z%9;ndo`M z4h`3u?ITtwtoguX1qZ8STEw>Qmm84w*ajrdV#t=-+n(Vce7dGfnC}#)=(^Z)k`1yL zp6dD9c{*6!%oE8elMSKvFR(aOC^Q%p;bKW-rKB%Pll!YnF+XBLEbCA5GXnqvql0(y zbZgoAgJXiCi{p}Z(o=WUTbs)4^ijBySSO&?Efbt!o{0_StJJ4O4|m@qnkrOabI#+xAFu`l1(>Nr`0(#aB_J%;y*G zZoyc3>aEaG$48Ibj%z(4!kA{uR*#woNL$v(x>vW!`xqMI-iNPm6?qXC$MbIx13ice zXidUTsX@~3lY6-?n*l0*J);51mJh>TMAmm;d|O|PKH%bGU1EUH52Fp6^-vPke_W473K46?lOui71uyFL-@9MYLbXXz-zKR_1%#Bp9 z=e%-TergKRpEo3%^6H|f8YRWW$vt+YHa?GL%aIwfv#Xu3PA*2=%?4o!4*p;+S8Ne< zCvDf{AK=ShUkN|K5`#3C;iL0vGGd|RxWi6ux67w_O?}9M6E-nvp|_MO^Bb=FdASL> zHn#^DY+sKMW756$DAARaEo=;XpFgIMqdOrluPA^!s-Xfx=nl4kzHRWiaKtd>!{g{D z02z^jAg;05eN|Z<)d5AF>ppwsJ|x$;t%0S)gN_rAL^XBv!mw!w%i2z0t~qz-$g?+V zX6barfYB*xT+pl0R+jAyX=fq9W3&7m$i;-LZ4e;7+j>)Rt?9`MfX%|)@G-fvfcEYi zCuc+8bRqN9ElGVJ2wSe^^F@y4?ikioVf)2$DLO$jjym^=C{z}=OiXO+nsB<1+V<^B zY5?Cnd?PO`g3cl%6Lwkcc^KjRe-rk#n?Fwc)%<49j_zAymVQip+Bhu^7@Do29r1W% zk|0yg_K!R1*Jj94RbWN)Pb;jOyHfxcH%4+Yuuzn@Gra!abMJp2SN{`wTV(mFioIi- zH6V-tMvM~xyQYmyhRP>%zPx9l2Ko(T!X*oCMnRA`avVtdDNoMZV;Ex+>B%=cxBBbp zHo;R#C1wme3Clq~-3gT6ipP2Fzi=xifJ2)MTTai91jj$rd@?-#{mkorsmu++=KgiL zM*wSJ0CV?uAdUh@5Yed*dbCj(e91N(2*d~>gyu_wQ6)ndAT+c_-4r9#jSNVeN`%c67wMz2b~yVbrQ-lF^HC3=i_m z27Pk^>#qJjbU#;bvBo>%U9E$pbrg(+44r2PHd&@!xIMn8$~*zLUZtpPdbw0drmb01 zvNJp$>E!1W**b8`Q?U`%pjR{qpB$1>mb*7aj{^bgD<2q0xddrkst}p`(0G!Z<;Hby zt>vNMuP{se%9aW-zZ}zj@nxK{;8JO0QRyNH)?q*&SM%wf3$N6=U7>j-g>WmX*z&*{ zd^+-N=Px-UwQQO_U|>v<*_`LS*WiAby^0d+Ol&z;y5$atTZ;Wd| z2Jbh*=!vV*pmMXxw4Tq{x)~v~-sto}rF351b{Z}{;rquXoW;A#T?zPWhQ!Ss=Al)l zx&F_17_VUGPP*EIpkfn>8CNatc0*FDr!l3js$wP_GV{^uanBHJ{yVls$aSN&F*%kK z7d2t)%KU7W!!dS2d^xT;s!3T#T<$%i*<2$u1*XwHb6gcFl13C=bzfpDqU#s65ixJ9 zr%|qoQKoC|@)`;h8;X=`D#eJJXP=e!-eyZfe&Iu76{E?RCr3Rf*hTBH+7!8Q6k3fM zhO$;K0b^ji9w$Swl(A`T+iX2yzh&FX(@ z1Y3{w$Ok9u=ljlo#3e%dK>f_!49(`JaR*0goa6#&DY4-y(V=pj`}i(R|JkN#Kx0+Y zm+1+^NuiRn;D>K0asGzIDt=mX?*0;nOlm6lY*MRIv%ItQOrTl9!hRWO!<`^3a%llo zSrNHJ+qy#(DDxK`Uv$CadwI7O*0=&0e0zx-KZf?+M8`bNSy2LY?Jz5pb^ECC$V5yaF z7mn=Cb1;!?u6FA|s|fPVIq?n?lZTp8g0?K^z{svSq)@g&^|7kOZn!I|IV z;cDqVysNDtHs{paL+d)<{SHzhr=XJTqZR3TGna#Q`}DLF1(L;@J_C}58X1}^dcXrS z@rmaFS)Nj)_945R`quX5Acc|O_!Pr~SE{jwt1{e=?!{HSOvQvG3&l8>XjksFg~Ws9 z`P>!y{VSlLfYV17^1NQY0ANJh(y>%y5o;Im`AU2WyT@~-FqA-01Sy1vD@##|dt3&} zC>R2TJ1@oD@v)pmKfRx%{2+EhKrR*2)p@6(S~zSX?yS11$k`~zKPzI_6jo|14gJKI znH>qy^fr$DIN5`c+7?2Jm&+(iT;hQ)x5yKSGo)6jvTJqor3_a?A4Bu=83n~LB7Mfc z--VVnQy?CQI2@j+GLCp@ITkS~YKl%rTg{gPL^0ykd#6E#BGX^u_B&aweQTFC(SD@R zBeN788^(<`X#u7wcFb^KSj+eSqGI|<7pI^MhmNI%<}EVn{{o?qMMPzZv70-e$yMtU z7Ft-Bhsi7GZaHR+Q6Y+CnPxQQ9x&`FAT~`jhqgFp@ItCaAd$H3=5TKwbC%YsX`L8R z&tYqgE>>6<(@^4kc4;dSv!U%txwQhcImxk`bO5axj};SeC~O3>%>%gCI_4?yWx{bo zSVKlXveunX{=sgZbc*<3>4zZf`($_c!LwtrZ?V8lMiYH4Tym&&kv{m_Y) zlwr^NuH5YN%IDT+wmlYu=oeUdB2a_VZzm>lTu74C#&-IT~ya^U;>nM~6?UmUyO9LjT zst=pqAkwK{uCv$e;M!=%j2XK*PNW<}-9Iam_!=5-mtQ~6-;o+>k&VISe1-PU`f}hj zsdJw6OcGRd9;zu6IS80KZ$lHZAhtm~u}2kVc(l_i)q}>OPFFiycQw{xLs^#uy^&GQ zvcJ6VRQh-Hf^w}~9k9aDPNwFQply%o%hrRlD7<-|9X;Vu$0kjU%V2iNXfukun+av4 z@!CFoslG)MCKIpL^XFsEukyR3ATjqrW(3H60RbRr8as2UIHH*xCvYad!E-Q_+}O*2 z@wP%mV=t;gN%ZLO2cH<;aO$E=(}CXf_lmmPXy|upNyXp%&~$aVy!)LmHwhZ2{4KnK z13q%ccX+)K5h~}|j&RCrnoWiq1z|UwFQitsX6jlvq&e&U>IOTdVY5a!Y`ufBhu8$| z&{<{xzaJ zj0_aUScuZ;7L53=b%__P<#O3j188V=&t&lqtu9%17^SMwlfLmSC0PD?LUk9u%E0J{ zwO{vpiLBV8-h08To`UIME>`6+c4)dR7gb5*YMLduh z6kvHX9`o!~*za(+kx6Xx4dF4b%pXd#9`7)*x7;t?Y{hCBY}O>FY9Wwq?}ya@c^QFt zMo04e3X>bRtY?Chb8?wu+JX;aVrCHPHQ?z}f9+P)bDg0~mHgtf_ByD{B9gQ+}%m%iFGB4a~ ze_x6_-@*MH#)@3Box{+!FfwTydufe#OE1{2EVr+4R)#jeU;`h>8BU0_MgHr`Zd#c~ zPu0`z}7irln22ZSnr-rB%D386y_ZSc;a{|Dl%hf-V6f zQm@gq_5Itsm1CQ(re3Vxau#inebEMkypIIGs5WfaszkgJMCjt#9oE@@{pJb3$QTvJ zo5fHO2H^dk#<>XT>+meu36(9Bg}1D85$>maS-|U%7qVt8=L3`)@ZKK5`Ca9{qV_TK zD~f1C)E5l##U6Ijk^%?Jg~0)qTYGaImw&eW9GFbNsQx_`eA-}#xI~GZf+z@#*Z%>* zQQ++jcHq5#Z*dc_9$*lvlt=M*TiIkTGE}1R)Y6H%9w;$`Ajh zMeOoXp~*|S@5q!evHB#P>U83Ac@J~j;@1VoHVguR$q!af-p|Ne zw%mVnf0XQ*Q;|MCM0cS8bXJOXiPc@H-t~2rsrofYU6v!ssmHykQGToAYIs_k1y)&Bxn|+-nvzB57>m5P^GJePLF+UiX$tpC& zNSAge)dhJsoibeGIgEoQ#_jK5H#i8((X-5t%A7bS9+xt0>)OuflgN$%?}1X)xwWq} z(<=hoxG#Auj2a88TPeu^<=Yw+8p}fkYZ}~_5rqh?V9FQ>ZDS;GB#J30&>@G_XX(PX z6mr=u`D2&k=@VTlc{7);-m(ASx_2+4cD|=hYXs!9>Jzb^{kD%ll&;f;Y;{gzie=~a z22Ru+gvMW?Pe{FTujSYB%#3~6?(0mE=%WjMR4ED%mF1W!ccQVe8V8-eqC7Wzt>1TM z-%`XlW?bj7|dNU7=@w)0W^k2R*xv0jJymMAKw04n;m(q0p z&JT_n5-tLm6({wW5*tcifcn{^g(TJW<5bn%Q$gF+%btyaiW8>5fk@AxD9xNkJjNI) z5w~pq%&^rCjjPp^(i$M5FPCBHQk`bu0pox2w!LIxAqWJTJCVP0RU-_D!-j=U>niS7c0s&)4h_^T+V! z;H}gryT2V^;?G|KVkbs2!5(oxso}PFX5p3%0KIp845e71#^#Sv#;YFY3rM(8Ef-7( zJQqf^W$%6--+y=9lA+sXg(JIc%E-R=z&3_yCq`Sq&*+T#rvt)RZz>gUut?Up=bIhh&q8!7pFASVTnk@(6U^1H6 zoc#TA@hvv%YvzqrU_U!87^k@4o4jF`TuJuJd47BMGFGd!{@A#F?bh1`ia_M!A5>M= zG#|RzDtSLP$T;$>9q+Hgq81QG4K)Zs&)*Z9ZKeTX0nF{MtPfuf zcl!!_Oy$O$`f0Jzgsfj&Gk0ubesPPpq@J)ZhhyT@FghZ~y9Vq^Bzso~wx zZ4e6d#2r8RBR8;TK~&Oyeuo|f%COqtmM=mX-vhikj^0;O=}LISRb8<<=W_hqQ(}O7 z<|azO8w7RCS1)H^420ZjUv>MinlPo^qc9(w4Z@%>!_7iMpyasff+*Bi;^A)d_&Rxb zluCI(A?ne_t%9!xqwzi`w(o_k)4R2l`8%D{8kJ6508SMrrkssdG2s zF6t#TsBXB4%b!qHWTKa*((4gCmZP+53}M$((I~59Ai5HSw-Kq(c*TeKGZG3vSRk3z zGQ3I&y!pvT=EHQ@5q1J2rZliuN`2JgO$q0WhPOEPlubQ1kn9t=^WEI|p{C=3TtMG= z*d5!r6sK>lIz&00_kA;zv4?B{$xZX=EYQ^hA@c=T1~XE~e{7uSth~hfWH*Dw>iW=N z8C#_)=lL#1LiQTVOQNu7i}xI0LQ$k{Fb~)ehN%5%K4&=T5a!Ox{^s9P#cgiCgXDK% zmvggLt>v;_?jI=U2ggs}M6W{LT)Ed4P`Q<7nIQLi%RyDDx-xV)_PW0_TZQ;J*8Bqx zl<)iEi(X!01~M`m8n&mEZ4d-ofXz7#R1L<9ckIiJ#5AW}@TJp!ZmI#(Le1sOa`+;h zrf82Xo^)%iv<8eyEFP+e(RPhg(;y!4$OjLwr<~Az%W{$UyFlc?(Bg?PNgltw}L2qq1HZ zzfrU4ION4!eC3lbEE7FYr(Xdg=m(>vIu`#Qckdn6)V8&cqF9h(p?9VC-m6L{^w3K{ z2rWQBK)Q&Cp!60(?*s@C5J>2t^xh#rXiD#h0xF2!>~p@o_3X31dw=(j`_Em^18Xdv zG3QzjnIm(}G2ijNYh97>g?FFow7B}}iPuU5r<$@sX2!YYAG_*_(` zJs(#28A-@SH|!(c#hfK#wgt1QVKjO(eM#)j`a57OkD+XWc8%>G870y|PF6!u@!@U$ zA9RortC3jZ6ID*PwxWeIbJ#O+RdHP9+E*TJv^1TD<{eVuZv?(UZ65hl=B5xc2(sA6iYseGYDo=;L&5LuR0hl zi()iWZET}0=Q#}OubGRgVO~)J!=b9OI9$Fa2ak-vCd|C(1YnU4^sG(9WRs>jEj{H=oX#bQwb{gz=A;1#!6j-Y!@?68E; zaUiyb2nQB&i4t^{t1?LE2UJ7u=3jOF6moz4;=E07M*k;4E?yYyU)qTagr6nnz9553 zzEWS?jgy2r2X|*<{Yz6QYqI>T! z<|}=X*)+ZOos5=2r%Kl}~yo5pq8+=6BnMmrlOZ&o?!{^1OV1W+~+c_8`8t5*`BslKWal6}q{m zST6_F_)2Zupy2P2#<)UU60@r7X0~=A?kcxSQ>3KQN>D@j3uGUr>VZd4>>k<)8To^Cc2S+Xoqg4>4)LcuIV082Bs#$MKt zQ{3TDOs}Xgiy^PX)jE-y+iyA-a_-sV@=V9 z+m++PH`PxgAtvBr9%|)8y9xGq0|>*@0r`dO1JvOisTWKM)SnrEtwrg4bYILID8;P~ zCWEPP;oWjCd<51$hFLKe73u=`qg+bNMU4ijyZd!kX60Wh5!t;tUsT^b#&(qizW#Y?{C}yb;Z46&B|8h<{ze<>h zhaQ}H5g6%`4&UsN%G#O2ZFnoKEZ}sk!+ARy7^B{FL_hWr(NwdF^i-v{btu>IbP?e1 z%*2`bPlAobkY{0TEaUM;m+}~g(S&M=ub<_ZjStlP;2R<}XNhC2zKD=8 zx4eNGA)zR);OTW{@zho>~QA`rZrQc~(AGbUKyE=>xCfI(A=o+;RARse`z(Fd3*M6cC$ zOf_Ydv+LS4k#@x9k+amI8fl&R1dPunB?OjTi~ltw7?dA z+ML_OX!bq`Oq+@;Px!DVHzn-Y@AB^as3(^Jy z`RK;@>_pU=17;LU&>55^wd?x9ZOuNZ#jH=#&j}%rBXeFd-A}la7s6L#z992q3T=V~ zCyiXvW;uiLjVR#RC_|)GDk|<7xG6zaz8X)=C*hp z9fO$PM>^6*oI|vl@d{&YnXMP?CzztF;L2cO2dI#JcbveTmW+{?FWB9>OGGw!*yCFz z`&WPw9k9m$eOLCx^lH1h^ATOkNr&h;%2E!#r0DH}Q)~<6mnh8VnlP1kbEZ}CR=eQ) z5O3<1QRV^t>RW`}IS2$f;2 zdX@8}WI=Gz$!C~!+{c~nokKM%2H@Px&1ybv^m&YoTQZYa&8Y(6i}G^gn>QFJ2W}u! zJuv;yljbKv=qkIXZykFQ(tK`Z`&?d3@vgHRIcl+gYH>4sZ3}D(?gE4;CiOEGPt5BB zHmcN{I&c$|cCV-0UM@aE0t&KWNOtV#M1xD!v=vysKT*8AgyN`8A#KDikr8`(lwj0l zX0>L$L1um%U&piZH6>pb!)-}j_SoM|U|N$SkE2_OdeR71J${M!#n~(iLSCd~KgW!Z zWA!VEn-lwv9&P(4Nyk)5%ziuW$tE;D%I=k*$cN!~#cQM$qnoq1p)M^;R`3F2D&_J_ zWi$*qb9il{G_cZ~P97AFE9cwXS=2c}Nb!u1Ja~*a#o~9UTPAs+?)4{+V-P&LCp>i< z!*!_5n5P}2WOm-~sMXJUbJZX!Dd=|jmLd#KPW_?ICXc`rC zQtIKzUThOf@>WvtJ37%Kk|%Xl>2NsutpNK~mtS9?;@@^!X~?Z_k4HmiEN6IY>I|(k z@mOcM*B-CcUK{ve7ME%|87C&r{SHvtHO=MCtqsECpUd{sxC8s?sk)#G5`6Pux(pj{ zPsxeZ6rRlc;Iu&wf98uBt0y93FR>GK6ZKg>d>zZWM9R&|$Db2?p}MCwP|0_FC?{6G z7f#a{LLqR+tYTbBLX!}`I+ z3@&v(6cm=O8_d}~Xy8-|dJt-WHK=$c^Oj2&x0v*pzitLQMRENa*YV~&w42v)mxJ;0 z!~}eQuZyHNt{#mB_prV)&LGv%4Gx9V3N(Z?Wj;5@OgG!W!bK%ErWh5 z5*UAh7?8o%g~T#g1Nk&qTH@Ap^m)A>^3(b9!8cdDj`9wEiWUdVer~*F6jBLG;swss zB5-@Uxs?#VyFoJ^=z-}pof*U6o(!CFev1QNL)Q#LaJ@==;5pRx1V=*#Tu?>@POy27My_Cl_uRO>vNyJ}RaZKtxaP|>lv54g#jTJ0MP-L3w_DPHCKo)8P(;#P-rMq%^nn-Canmtr11nCkCb*#En#P zaXnaAh!k<;Ho@4vFyy?~JS;s=QnQQ1MvUlKZI7Ci*G7-zcm?SK+#~u8B{a1?pb6rP zvxsiXMwF9voW|HtwHCNqgF0j4V@x=it}nYWmQS})h0oBY!GCxNcHA8?M2 zukfYZ?Jk&R7A|cou1W~tVU&nWi7cxmHps`cpPY18XR}08!qN({aNBy|pgUqpc-pY6 zahlc2uZYwX0rw*|=bCa?=hWj+b(qE%F-<>XZe>_`flYA=Rv0m-n;<}zb{^!rKw}lC zAF8FnCV|)e&Sg%vFX3d0i?h|ivrh{yhpLom5#dq&H$BYWkF!Q+B%L@E`?$50E-_H% zdAV672EQy63rtR8jtNVm>uA1i&cKKsaZj#uhj>uSO3}^>c|Par|9m{)*3`XS+~}bB z?dlwIWO1r4x?t^X!D|MkFDVeul?wFaR|$O&$M=L0_WoB~`Bvu+1k;NZ?C-O8atPB| zh?m7FA*@{N`kG|Frpr!!(LZ)7b@end;C z&?o^AF1EQvX;a>1^Y5Gh;>*Rc>XP?Xr|kGk*m@>7R3>2JuQ#mh09&DU!(S}5##s}= zFr6-tHB&!U6N*>Fu&X`DgR(__gwwWr&a*bCQ?5SMp+Q2nXSH34%u$EXKb4y8KHO2 zo-V%TR!Fc%O-e@ha=Al<$ttm6on=4-2#_B92U(D~$4xM6zq6bjJKNjm~Z8y(C;WM9rIB(r7e*7S zdYm(~x>>cn&GCXeRxXadi%dPGIM{%h6G<$~pp@DK!?Uv~JP0iK)Pogn?n^S?;cGPJ zi{m0Q2YU^J0|v~xU$KMD@Axj-i3@G6aFHqWQ*z2TnanSmXPLz&@jvuAW%2=K#>qu# zZ=~&pDeTP}nN*w=qaHd%@Gk7r_5k+{^%%0E>L!7?RE`i(iAFTyi>1#x4lq^A$>F@~ z~)fS zlIN|vZg`C={I$AOtCnQM8m*;n0D~DF)$5H$-4Y{F44B*sAf3wo)%u>-w4{c!K^>*t zlSXDU^!heRva`bTQKV{aLarChiyimAz@!d?3(f6w@%J|#qP4rzMPgM8^$-kz5Wu(HWXOhUM>Ezp&2EBlK+-rDCDVWeQ#bD9I6b{;QYkkUW0Ce*~Lp^ z(G9KGL*cgDAY5oW?W$p#Df@f=1}timab2ThwgPea`c(lz36(NLmJ+s?J=MaCORal* zys@qnRh(=$1=&H07EMX;Ea$?ps9fkgsRUc$-g#k5XOa=t;!qmi1-WlMRAWG%V#h@* zQn??sWEXQp=|yB!oyt<+Xfc9>B~wYj&!#@1#@Q-&o`uc^-TzQ=9#f3CWdVSdxm!E9yW6y;O3oslZK(Di<7C-jI9C%GOoXlYvPt(&m+3U2+XS!v_iwM%nh@D#OoqvnDwH|tCBnmpI2GD{a=ZhxG* zPR$B@;7$R*)Opwd#qbA%8wDyotXhK6ZvYfU(gXA^Mu+BwJu5l!pSsuU2Yb%m@ih`D>F)dwyRx`mUVJeaP~21#xSbw0W@_2&tA65 zqlE-u$6NKsGm(=q8b>CxsJw=Jtm0`*!vdkyn`UN2wU-ypYUL~;w7?0l-IHvqq3!1( zGpyxjg`J06B%KwZCw7BR4@C)e1x@NW{IGri$RG*OxBwV|LAQ?4UgDXmP?3zIK! z*VJ;hOL6yKN>-zWMIu$XP~I8_)OUjeXMfZ-y|KRgs4fHxpYJwUHf)jGZAzW^QS&Yf zguZynfLaxN>lYLZ_dv20NK9ayBM4CuBhY7r0-_`+T- zhdtkQ3DfdONKB&RgC2_)V>jSH?%7vhC0+U{!$CT^iHin2bnkEw8dG22Btf*`C zaD}U94j`C3Y*X-f(H+aHia)NOo6Kps`tVqc`P5goIOY1X?t+cSvXnx*YYALtNFBa= zT=A4oS=zLIJmLy5(R(z%;Kq)Y)O~|~*?74kpLn8Yak+2qQW!Oqw4I0%tMlYw+}={)4kPHu~>bc0}QPT zV#ptp&)druc$qHPGoh|L(c9PheO~s1%8l+o9zw13&3e(|XAl;-rsuQT*L&-f+JkiB zPZh<0v}GH6Ng z4R$iziP99*Y#5=KYT5n-jyUx~4kT)_G?) z9+`8eSC?3%6Q(QV#=3DWOZT`ve$79_oLW2Y^b_N1olL$+DAq#5`Z#3K z;WAC?sx7)YEtysv&Isa)^i)%9_a7j6<*5y2zt6bpJZYsy$DrbcVHbN-TU;&5K5h0s z-!kfis%}IE9G%qdF33l!;Qaytm}2z#M*3?R`G@7~TJxfHQNcj5;KbC{p}Dgp`d|{Jf*X#g%BH*oSf&a3Kc-Xmj(hS1Oy|A-@99)7$p*XZXw7Xyx%jH zucfn>M@FyT?kieYT{J-0`vj^62JyDKG6@DK;ns9|#bt$DIV{$64v38r4_YQrN`<8t zFXT8x9fzekJ(HbIKBkgRXV?_RIuwL8RV1K$L>6w>yW$M3POBS>BV_w?*>6_HIgb}g zh>)QYb0j>Ycd9A;tuvKBs!2~xkuj6t!TkX*){Fpn04wJ?(c=fUaYVY%rw*n8P6i~q zgo&EWbS6{D71}GY3Y)j{qUaws9YUOtCP{YLcHK3S(2?0yPRJ|zp-COKBa;Y-taf}d zZ%5M%?U2i}zQ&f)B4b#0QNFB^w|J#(&|siyOynKO*&|c6)_e)34~pWOd?CVWe2?x5 z?57lK3>+~%^8oDi{J_2+IG$=KV~!k0OtbVcT4E`J`5^r!h4O0y#LdC=NzhZ%R)ak2 zeDTaG>ylg(F9l=g?bc0J=TE>^TVgW(*by7@I)cFY#i;J4Fc&w;UK5GLpzH}Anwh5;|Jc-SES%1yyfy6hAC!i4})+^nq{O)#Hrc)G)M&AA`~ z+Jsw#PNAeG@{w)KJ9WoNC(N2w`^9)=qV?jri-_= zCOFw$S^Zb|tl~1P^TJ6gKwea}{_Q168EXzxwMOGgx_e$G1H)M|KC@rGZA3lUSvh;N z3#$rn=^47iWvVJQ6TFi>(>WXpi7oa=*!jA*0a=2~99*T#xtpR}!-w%&&% z6R)H*b1EGB_<^_Pi(*$=5zJQa&yIjyN1BPH&={+>`qKQ7Gqw7vM`fS=b> z&MH>)9CSyDHXx=Rko7)x#Dp42DbfCZW#~KA*w}+D$x*TG<}*M34a1;~&GFMu6!0bW3ORKCIb}n{EfH z?#G?_#!i!D_g(FVj_5ao9*f)2x9l`9SR1216U z?@13B>K3wyi(X*JO?y;c1=aJ6(sSV$H}?gZoSBEXfBmc62K5{uOA)i)XB~ZvA|2I1 zI}KHn5=N~w($%c@GfuH2_pNaY|e_eRV1wGHPiAq@lKPArXhVH_)QZ_`|E z0dStvPd&>xkfZ|fwS0smp(7RCB?fCA*WNUgu|^uB>}-PTWW-9%GUkaagIu~W76vNt z@;G!7((xGq<8|7V7D%`)zyJL0cLmZTml*VnhV-|@gDb_Dpn~URwlkcr&e|y)l)SRL zQv#-*e!NztoyL69>zrxOg|C17Ypx}HQ>R@sS~SUmT(I8+A`(|jW4SayeYV~*NF$MJ ziG_*jlyB-u45x)`>FkM^(ig0Dph)tTe3`b-+-2cOj)#PJmQ0(I^NT+Qz`gs=|L7Bc zD3X@9N79Hi2ZpX5-U{RWXs0^lzLe*GYqYRT*VC-cvXFM<%p#E;EGomLO4Y;;0NLMJ zWw4nuQY-bQ4~xeaAvi2(S;14@ghA&nq1v#;JsZuG<>p|QE6`K^$FkEmiTBmA(5FbN zU_=UJXEgtbgeDdSD$WkT1D#=2*Fq2DXc9fUt_wX}YnbFw1_uz{9kL{If^{N>iI%~m z!^KPH7{JPGOJVX|B^aerQ89LaIo9sUE|3<0USqToCtxI@wOjBGzh_Wk`TE117|*!; zEf4n?WtM%S*}|d7n?`{rlB`fA19mf)87f!CEMt@T6zRqavz3N*KI2k-_xKBgf^Pak z++x8ZW35f#grT}8BbnWkO&h}`EeS&SDBuuZ-M-_4Ky(=@nWmN1Q#*t+1QbuzXz=3> z1H~;ngtv7o0?R5I1z>79Q2TduY9Z_aEZ#01gV0CqX792LqH^3<0S!euqe-wgJPd%$ zee|m*2dd?&K3^KB3-Vi5R3a0Kv_t8xa;inj+#ttiJ^?JU_T4zmE9Azox{d+va{f0p zdv)+Rd|?L^Pw&M1?q8#WfDBx>?gpQBpAgX7zWgvci-q%%voddkuU7S`;q3KNGUknb z(1syp=`G!Wq|0|%A?cZ!cX|gb4lG;T>!dkZh|%T5b199D6v|%I@UE@`sGCZ9<7M$v z{Tgysm4^^u@x3N@#LI-Nf&sw+KUB>iv?MTWfql{`CJEaOX0+S$`QV{3vU`ArcM1u^S1Hi5kqGLscb9LBkcK7kq%cqgwOu7ze1|z2tJD1o9}Da*l!)Oey-#%*QvYz5`uQ5|pRjIHxJdC|h;feRx59Z6CA-+jWnBBw z969`5hpaww;m{UCSGK@c96g6k=(;QmqvA!2MGH%vWrT%(@VNM{B3V++vjmK4tndd7 zN(ZaRl7+Wddy8bGDaR7#+?p=q332UfHdUp}VjIrk4HPP_rnH8E4%zPBk!uCj4-pYL ztpF>ZK^|8^5Pe2~9waWZ`dp6u#P3jcCA+p#+dEs3zi_B7bs6xIglQ(=;gf)%@<57| zK4#8WMatfxWzBcJj7!K>qcsuplmtX_E;pZ4X8H!XoQsf`m4d)&N{>cq%^_LF{hYOU z@Yt+q2B<9sWjN%Z?RaFeZ2Ri5bkY!ir$F0$*P~V~NH6)$GLhc#=d8kFfJmGMRBf5n zRr@_EKfn>wEY(N5n31hd3n>2L$U|soIcb+E*ivqi$Bv1dE@%ZPhnYVsiobfEHYr%_ za}&cwSue1@^+VEGdS%;CC(ib3_sE8Taxiu#TCGhPmYq3uWI8v)$L2e(n(C9JwO#i< z`?wsDB~i%8bZc^iFb)~J>TK$u3k9rgq4qzt_kWuMudWWx5A0T%tZi*w$vl!DMe9-Q zvn1<&;_hwxd92(=v8G_ zlx3IXq1(~myc#-^yr+T5$a*M0_OrYnr-Z~{j)$#FVht5_TGw6kuy)p65-vum=t# zg;G2cFDgBI?6-sf6G%IIm(&7x{(?+RZv}ETtF5F;8dmigacvWyfrbHw3=#OlE*Y}2 z-dRq)2+c=^*D?>@8$b0j8AGKkyoi0jY_#L9@@5BN1mg8l73a~EKu=V(Wql=h_B6uw zqM}ugIsX$Fo|RW6`+VZ~1Q-+*B6fE`^d?tkHJglPVoJcJoSUsA2Lc10Q!dmJIc1eY z{iaNT1NF$1UO^qG+`;S|Cf-U;$y-f3!i7R(56>XnErQ|Do-^U1=ud>doaAG7(;@mG zMB+56Come#jw)YX%BTQ~Tt1z!i{3&qyZ{ebgxM{B^C#>(YjF3W#!J5x0K6h>;jIO?sTwO-6tI*qt9rs~5<5>|S zH)HFx7*}Ux=$8N(vJ(N;+GLAmm-W78hPlv^xodFWXOR0>wduC+W++utkVkf2CzFW~ z8FAP|W$BntWxzU<7%?>l9DXVoCBrFB1>5UcO5s=1ZD*Dq{CixnM~?O=-8f~qAaA#f z{0&EW)%~w=_nX1q0zC@t`$@2SA#)i`ZY}xs-CD@WkF4iZdA~XRW~Lm!{m8TVEmSJs zh&PmPJLJ9L=;zBGN~OlX`TS}AV%H_)By~!cHyxJA!EcE@xD{5iOf?izXR~V>GbLR4 z8}II)Eq|SF^Mm+8NKtTGk9_B6&{f_XzSS6^50{a4TUYlRCv zlRGzh|JV@u+i{6~^FDn;?LO7FM!Pz4UGJmvXvJ}xO!(!x|8b@nGzFNcp zD|;6R>M?M_2&uQt3x+(XXB!#gldKyn8k!t}wSk1~AnLZNW|8UH`y{qfb6x|8)^-5K zMJNQ%`B;Vjd5ouj@u`P;{Uk6pePQcTpOH)N5X)wvn%VFq|8eORb@|7xju%rk(Ts7Z zp9Ek18F{fe7uQZZOXhrg8TkZov3GH=3)$DMi1lRBf*7jaX`T}NSevcnq`bhw~SjO|v%;wQmC=G(Ts`>;RiYG8|aax9)RB;Z;8^qq5>V^PQE zF(?Tu6Ys-}JN-Jjxm3!m09_8R0n=Fr=zy!8sc71^{TSj4?!3bzDK8G0Ort5fbyh!V zRCOQo9{;pVK&aE4WsGm$`(qJxb(I9hyH6M)sBaF{ zugC4$1?{X2tc2D)n6|6EUEvrw9nCa9E#lVWT&vgch0gwaRn)3@`4blLAjVo3Un1;` zZM}8QmUd_g2IoX)+cxQ{lK84NZ32^Gk?IC{g98CPJ`#viJsW>(WP_=8A<986IrzI? z&sq!Bs(ofGDi!Xzb9s^p(w*f?`~04!M7CW0JY>LJJGpz|22Tef;YoU0Ey_r@ubB~j zDf0ALN6pjA1n<}R;TkhFOi7~3bCRsy=?%SAzJN&x|2B+UrQ_YGYczt6&al+N3;#Bs z);c}y5(c+fIInVKtaflyd01B=E%M9^|2xpi+OrY!<~rg#E&-F&R(3@j!;XfCHV2N);AEGdtMy zcv1?OUD6L=Px}70>}fr}J!bLYHMK_x4LNcO21I0nJGT)R>z!;iN+}*IQ&x#6`V1<| zt#l>t$~T`*zxOj=(h7+6D=6*3{d;HhCqYaC6Dc%otgSqsLH>=&t5e{rl&i3k4B;MF z;(?v$OAbBj0`~!Eq6Dt-%<5}+P=$-3ly`FTI!UsOfmReImR*pw4-)@+Z%bXTRoi#U zJc;Uc$wU^YSR)IDRvnyUr&F~@?N5`t#c+-V(Rc+I+MWQ3rm zb-*s4@e2;3yKataA?d)^wEZ>tpv~%R|PhZQ=Z4Z zv8L$-rf|di-%IB*DHXXgXp3)5dsI7z$uTs+KkIVH^sp+l6Nizfxpso2V>=4!57TT?d|j zy#`F330b(nTC=VlIetwICe~&zvW9HIH2p+5*M%pD5Zib;510 zbPxB58ezrD19*55BWlQU=!hWV4IVy@&(m?bE-qBq#I4Yc;Kt3f#!f%<6_9J82C19(vKkMje|n``fui4@B2bNt*hS8SA7TO78mbM&$P(M zB)=Vs!C;74lUY4HlwdVUg(|Tks$75+8Hd|hF9x}j_5=r^Xwln$M9ANc8(aoU6sx*k z^`Ec&@jh7C!qt6n)%fx7fj;8pb{rUeUCk>1>#8h0S{eG z;SrN=Di+-)G>`!|b=%EZ_~c>R)M*y<1_CC!13LK6JCpuOM#Nx+i}Svlw*FYWUSUC) zVEdVT)>axkyPlEP5s~;uf2r7*f%>9qfH20CmkOziae=QncmA!h8 z2PSuv982E%gK+9^$CX5xuYC^{J3|W0_&nKlpNZbqd$YU+CqTXXo8upsS5p5fWH5Q5 z)w4{?82{t#WWr_iKltMRMBbMAYtrm65EM?7t!{VZ+e*W zUHXaodu1C%s-2DJqlv#(C3kQBQU-bcXTx7-_N>iSQ|jP?w^IBj9hxK0Bs`sPe4oAE z{nM}^xcvY8w)?B+%YeyOF9Z0p4o*&9V8~7WqE^3T@ehwd@Ii*{9gl}d<*_mCypA=$ z;ph3cM@gc(8Xt)*hg@5!)FdZOqba|i9)7ofKKlOGP5!dDF0}k>K$>@i7|e?L9rpu95Kc?_E)Q4#+BsK!y88>GM3+7h*@Ss08+78ZWs=&-dt8kAGTsuNlAZicVW> zeQ@;UReOrR2l261N36d3<#7W$AiwN)f42K|{w=b^%sMi{j;z0&KVhzR zzWMMk#p!-9{cj=ntLdNCjo%VN#;m`^+$FvIujKbXA}`3oaLi?QL)m^35a*7~D&+my z^4FPMWpkC~$~oDO$VaHIUom&N{+G)y>(}Z()Z&H^!_!Az_wg%D-3P?~^ikoP0nTi` zQhxsaE_1Am>cQ`Mf#<*a{L`Wx74@&m@K*Sd6`K58%w2E)EwpG&_1gH-eiFF7#a7Xk z{@L=^`M2f2;bo`kyP;%HznYe35Gtz4)5m)==#_Haef(6qH_YGw3^}>7`nNB>fAy2( zof~Uqej4?Cp&CEFWWG*!=Q>vn4_>nTb(de(|AC&yZ_fXNJZ_%B5>IJbJpLHZ;hnwI z_U3o|`d{9JFW2G;5G_5;<3?bpgnzZ(sQXWX9*zEg3O~ZXHfR27jyG~o1XlVEomf_+kZPhr85m97X+ZWvCE%X_dJMa@qFix$Vc|L*0NzSX$)Z^<~) zZ?qp>6o#ORd0D~jA6J>l)Ei?DW#^ti_>sMzBAyzbJTzHk^m(Ep_~91x((qr+{xYsp)%g%L zSw6iy7h|$L2gL-mQMrW|)XdZKbOS5vd$ww-Bc-R9R{>@2i?%_4y3k-lUKQ0cL zZhw>#3sfuGf|t2^$7|2AC8>RO_wa^#s=4$eQB-+YW!s7Av5zqEyuov{{_hj=my7p* zJ?t&~tdiUE=jH#`@67+c4_-Bf#TBm_Gi+zFv9VT0BP}hc4^Yw2FXB8QhVvU0eCh+F zh+Iwc3csFi%bo<)8)5#L{%UZRGtIIyg(l;dA6*VTg&9ARqiDY3j!OE`E}tA8ze1+E`fRp<@PtESN=+j{I?e3RXF_h0 z-Nw7%O3!q5{xDQaVT|C)A)-z%7L2O2yhfow2^F<-sQ!SOz zBdH0Y78?{*JfSJTMC0p-r~jv(kFC3Nb2!UtGWJLUH5!N+;s?yUOOoN_2oe;I@EPa{ z`-F~d)QRBqH88c;%6K%su4;|@^cA<$xDrJVjy7Kks|J}vDaPGjB^8PpHl8uXE@n+E zRTtcC_fHwkPMWtcw?hkaTiQohdV7}G*ab0-Iw6RTyU6T(?`xd&a5dM<`=NMEKAJO$JR zNn5UM61Xwh^#vk1+p@~;(%%>!p(#1}BR#i>^Hf>K}ZsXLI ziGgiRT&B0c5r*0@y}B|#H@%pxgJ(wKL@+P=Bqj*29s?W@3pH3ZR{v-%2W0ijxr@2j z?zxBY6<(>D%9F5UiX2x__-Att(h6R~`ywf9_D`)NT`>7AAMp$$|F7S!nT()Mrw;1G zk;RUh2NA+_b2FSR_P%BnF-2qPx;lo}UN=a{m$qr=-tDABJPVZ3!b!rl7~NZyjwXF{O{i@rZ-f<+HX=k2Eyb-z&==B~x_&N5linaZdOpBnmtWnAqF@07Pz<7TUQ z7WGfsIW5wMn=376lC`MvfC!eZ9CxAQ6YX=(3J)N>W}#YKqhP*I0dwc83!(+HN!IG?(?q-yaj{vlTRDY_*chaw`s} zJs@x$uB+nQ&kbASr%ZO#mQ?u=3ytnu{v`i`(lIGakhI7BRU zitAMieGXBLPkU?emB!L&aV{Ru>%HbY6f zN!jb3s4?WE3Qis<*D;GlqOwyil^G;duYcR`#w2FTrj#bpue1u3Qy1zO8l}XE-$z*~ zRP8IOOWb`)lq%JkyO?Ws%Rald2n-j#-;-)gQK;2qQg4a{YuCQaHYc95VVWP+Kq)v^ zcAJ!GWa=p(K?aW5n9+iTi=sW~3vVNxL9WcW!C0LQt^6xeNf{KWdFEsWRc;mWaXaHy z8`F18o)>MU%IIUFDR2SPddgMx%_GH1h>%l*)d4EJn$1FE26r%C1_)ox<^yd?K}b!& zSc~SCmqDi{ZKpf>lEn0nTnfu1r+%bM7U?}^uV(~DKzUcRvGH_6=Gg^6HZPYwe_#*n z(EK4~IBBG+wy(ulzBZ0Lobnk;t8mbQAyr(!s-V*3;J!dg&wEdE@fG{iMY`9@Y=^)` ztJp==*EsM|!>(7V-%P!#BQ_l2?&79~3|znF{sz`X4?RKD8Tn9esda(MuGg$rcc{*geUaLTwiMm! zjVSS99p?lbker@^H&u;H3C|D*$b#J<#&fPu;4Bxgef$q5U!FQ|^>E|5upO9P!gpTc zY4E8VcW9f8lg$ita~42{8X%bUC)>k1POCE(@+M|kR>ji1!CeQ)!=(Cu2|jv+al5q)GMi=X{;dHi8C=d-r;w9 zoFW9#*D91DrZ@>e#FbcT&#tPyiG}W89!?KoROnfGeL|9$_i>ZxNuU_T^LsjO$O7V> zpbCc-qo&7u(_jlz!43tn1}YYxHs*KFXI|V<;2|65Xatau+Vzc&Oi)i&bRDVFRU9Ev zUR0lm&PlxK=a`Iy9C?h(?<_SUT7kvQ)U)tj5)xDwooCQteZD}toN)qFXOO)A#Z$pr z()1`!g9!Hh&0*rqBs?lAxiFd6))O!0R@w{Bc`XoWDf2>dv7Y85LyTULK6rjga83T6|vE4fCNHQN`TN?LPw>9j?zm2l_p*3 z3hKJ~?Q_psYmamHxaW+!?>YOQWaJqsGs!#O`P9!>dZOsq&*rsvL*qSHcVT90A_W|J zMGqUCj~JbgWeEv0&J3H9Tp*#yT=dr0pXe?mJsM~C4(@OlZ8 zimd8gjWj}WKFS-6cQPj~NTtTLrxpF`u$-D}vA7}5vkLc$%`>vT1lrm{>Ny9sG@X|e zx9jPf0rgEO){B87IF^>=)Q*n>1mq3%?~Tm-AveK4e7fa|qQ83Si*km-%=VX2u^*Mn zM7o0zQ6X^V)RPInE%oBUog?{q55H95@T87u4nfpvk7nA8pJszj`L9edY=dm6CFJl} zU}qDOV>X^-*l76B*8=JV4;>20=rD`_l~Hp#{%k{J=EAeqa(Qfxwq`oPFQEBna$7F& z7oF)Z3+>wOk^0>BKKY$-&;ums+$Zg21Hy>=Tn(cl%K@z&9?qzJZP^Ml!RS`BJgm*< zuW#K?5B!utfFzUDW3SamD6rc_u_0Ske}Dh)VaC7R|1+yq@sG6f1^0jVw7(wwyW}z2 zt!S<)Befvwuw8zs(YtTM?H9hIzII1gcjHOILGfnQ>9HkRwFAHXCbPY4eYMi=?v=sw zcxt$OnB~}zZS#Zw%J%;|Q?MH1d-3+)$JwLoE#c~nYVGJmVZ|g?UkHaC0G9w%W2MZ| zmJJv9*Przt?M{gw=l)3gM;4DO;88XSf0iE1H=fF%6W|ad;VVOskR|HT4ZImq|kR|#A z(nl>}z*llwA@vzU4%i+QdNNS?i~%s@|(rr6<8<>H1v%h3zqaCsglzCI9v~ zsVdHF@_%kU#AYLvlS*T#FL|BC**dV;F;x{`_sc^UpIUzX^9-VGq3X@T+1Xw?*}q=% z?C-PYR>uEE`-@TEVPzM5Q)o-OhUWO_c#L^!K>^kx&kO0L`7jn&WlsVnSQcuvCCrifsG3^!U#+sjfe;MIqe@w*q9`E_-9;lH1zeiBO@-(p}ejRdyx0 z!uqhY7;8DxSMkk-Ifh#m$KRAJt}VPZ$oXq+Uz=kq+i1`ATp2x6{!crn);$kPp(XvR zOT7BKF*v=~(sW`oM|+Htji+q+=Uj<>*t*zxS#C#BDu2z>%h^jGl7lK$2%6o0Uzn3F72g^DwVwlES~*%OEst zb936)aot7}1mxCufCKK6Cx>#+4@pr?)262TI!ll~Fl%V2XpFdVS-~FDhvIl3V}rP< zPVD<=bqyASOSsz8lIQ_2SoO(Sfxovcbnt6+a@Y2iS6^-m2zHnlDSjTynj{3Ez&0tt z{uUDxBYEex3l;952LVH%8TeE^Cn6@G$Vz9qncL9=z^8ijk2>O=&A(3C7p6n6v+PMp ztV4;JEF<(GC9A&K0~`f=eQLrb=|2Wg)`iO_H2x_G&C6f(!H&t3lBbp!% zPTn4JiahX0v^ELl1unQ4(7L+-sqS{^cA)-d92Gsk5m9=sDB&+*(Aqo=9;y=RH+3>OrS$v()GsW)co1ls ztFiq5%Od@M^kJ@^rl_6!ACu{}VWEfmW)+evoHa$fa_d6)<=X)Nc+f2myOfTl!ZtNo za_b1Yi^x`Jh`NmK6nKjLvmc#~uq8YqUmPHkvgmD9u5K}md{Hgm^C(2Gd!4ZeE$v6# z+e#2l5wgb{9N7~^!yOC0?ad~&sb?EpNt@lX7MQAJ(0$!}Sk3T7Aeef^8DD;h19r77 z;qH&?A`;RKZ0}s^GR8w3l37z6VHqhLoB@9NUwO?uUPK#o*YjDT2blTwkP;w*vVe=( zk3ti4bDhV|OxSH#nY)r^tA(<^RJ${G3sOfC$Bk3DjjFQmlk@FoLJ(`uwtK($d4u9{ z-3_>+Lsn+2jxhSwYr%i6JNb!O0@>V@s)TqtzcqRarm&^LQkZ3CI8J8H$p{?mw%i_{ zl<6RG6&D#vJofhW_4PE}hnEZh`%4#XxR5n(DXmE*$f7YkZeZ|T`H*6op&v*46|DNw zRwVXTkCIm5Mx_d`7BioX-wQZniFV@zL)ne;ns{ zpUW3!ekG;SyI;43P#<=TPn|(;%=5YGrf5r#N8{4(eslQ|bmVi5?44IVTmY?*X59 zu)j*Ay|u2$-Ipb)lKxgGhP7KIg52(s9_ORnuhG3xapn~H#qFje7ye9D{(iDYD$|>4 zJ~!X{HN(gaN&RoL2>QEhq7~;RHOk2>d11A;bbW_4R*gp65{8GIKz2cil_KloIY#CKZk1px(K z=1~Tmvh!R7WL4PcT#G1q0_>jwdx;|!^pknyGyLp0x7TMducjA2P-RCSODfg;!E&NQ zIzKZ@i4-Ew3Rc&;##%HvpEFY0FwJLVRc=B}E!YtXtJu8h4J(2iubLDiYh*4{^gK<= zSOB%D7j1hhlno}AOmQzHGdwpS&k*@e>~kV-=PC}J0y@*Ui95?NObeuucVVkSveAge zuv!^yta_$lCmTGKZ`O_!683z^)@j-ZIyW;b3?*gOpG?Bx&DD=6Z)&#=fgCbH?`6S>4P^Q5IHLhIe;5ZDFC?GWaM#!-X+6?yb@?;dWkk*io@)+#SdJAR)+7Y z!DFMuP1Y{cZp1Lk+c3R$8J3oO#5->7Z6liAqNoi4N+OA*PlAbExIS=xZ8~(gbS5D?YeqX>NNFXxHs+Ksh4CecegRudV|L7X4!0UeMc}oH zw!C@gXX4T6jSIMFjHp_c)f1mkv0@t)Id}zwI`;%$UuMmC*K;T1`%$o-R@i0Q)Q02HVLr!hFEfmSJ_eLngjE zrJuWvmm=tCUyf?=_ZIl7KBz*UpC9bq&8)Ag@g)m%{LA}m}0~~Y-RLj zpy-|LpSkto`r;M_N3fg&JFq@*Rc6t5Nw;z7uGZwOlyQsOZvXSl2zk>ci+LV7MN^Qc z32iKVW$-!-R!l>F7?E*RzxGAcSzEVORXfpa$A1RFzf($GGFgYm`=$ayKNw73kvCk_ zTiu+zXWP++5V1T@}j7Hu-h1HPzxDi-6^_fq3>6>oDTD*K&x zyX!!VZxIK0t@`lVHF&0HGSN4Q$F@QTv&;aBOzW8&LWuhLSk4=}GG}gvySu(V!nzuO z3)e65W14%%GB6)TM*(~Gq3*9_#9;O)tTcQ?2q?5N$&*wl&nQQJb%Tce?T?=flyzYCaDY}+*-H};=fyW-L!C@p6|J5Yo|APz^geE zittS(_~+0Jq+3N!y{i0f+Az1K)L#kf5 zkUtfD&k%{AtuC$f)4G3H00#|wZEO-p(W%+$uX82lM;sA)0Q_@#O##30=-g~wMT{V z^r$>U@u0g~KtT`ck~?;vFIotQmHf5#C7Goj=Xgn>wt6}`-J+dAtFP-X;nNN%Rj% zY3Fg0q+PhsVig;o%(IpZnQkPuB$yI7SLO|t=fjxfcDt-Vy>;HS4q0IY6P%n}?|y>s zSLw&HNJNn+)Il?3*C6^_ipTH|fqNnYU_lbRroMVPW>{VLr%{FO$MCB!2Kjt`GS%y8 z3jln7rv#*}T{5^`Ngw}e&hh(*=Y129E}pDrdDAxk?w@C_<`_DrH?0)2u@N&f-&-me zPI?=EHTp`=w{czG(CEg+#Z39xzSr9!z2JujOPPJiFn{2c!m{1gOkv3vgG{pb)Y<4K z2u4CKNJO&L^TR%m<{CFQs##hfcm86Z7B)K9?jU4YT1-B!z2iIwT@QV!@$_#YeT z7`JafAH&fV!gfdOwz+%pwXaSMB^%q1FC)yJp*yRS+*}<($GxyivcV7|8G*0NZjMf( z$_A!ARQve5Jx;uVu5n97D-r?1JpO{sEe1J>#virD3e+X-sz{a+QIE^iD?!ppl5lD`+RTn34&v{%Zh%@{`q+7_W^d!wrz~j`n+CfCN;>jQf#C!f`aa+^ za)1}QP`sy?c2`x*=MSsj8U9$pszCUMbBj5nVs*h7Tq&8&7EgWoX2D?8%tA;bRjOrdr=`IIsJmN>^bv~ zejFY=ssfral7Vd@%_aGo$r21v%-jGcV^ATL&N!psx+y9h)X~qEB^q4Bh7JUE4Af$q zPd90@O$0=HR@obqg<>~bm9EJh5~)Uhh;0-u+ZCV*7le143|LgfXu`%g6Lr32-4!pr zWuJIsggaUmU}X$*0~(m%DsMSC#D zo@!06?C)gYd29Q1l%Ayy622-<(S`9(T&kf1Ynd@{1{P2N_7=W_Xvr%=6j4z|J*ms4^NS*QBu&A#Ov5u7nV}z zz{P>_;z5s04WFZjA}nO5xq#Qq<@AhkH$ZP4kR_0@$@|Dt3lK@bLm` z(iGJQQ`Y88r9j6*E*A;%W{?XW-74x;^y{GFQuBS$gj=809X3nH{s+{fG4h5|LP_Rjcf0mFACnI=XSWS2oM~E}=7BIw1Tw@GU zw~KwSS`lC85X#&bYqf%~>9k1=PAnZXcIl1^;w6Mt+c?qGyR)jHAk-bNrb^dP%q#t& zedxQ?o=?@4@2GblOMhtWDqWhlW$CqnI&UB68)s0r&_1S>r|c!q(3^i{tsfFB!6w4E z)e3@&WH%^dr37o2P*djNz5+1QoS4Vdl{cjg0EJG!Ha9d*_u}P`#$I#?)rOtS8@+p@ zru%TGR+ueqq^3J1MSoxR&ohqm{bK2m+(B{?Y72pfxWvZD3`n-8 z5hj*-fJF}fDyvk5FRNkdaO}-j^T0Rlm47({|307kx0CQ6+bMFr_=0PT z=hr4yvTG8gLoC@RCl8ew16f}d+o2m~?IsIU02|ZLQfkkOp8yh8}~k9l!pH zx!C8C4AX>kauh;WkBDEj#&&&{{1)^Q*?u*WC3Le;syB`Y;)Hi9&9>|H;t|a1)aW{V^NSm5ii@sx_~~QOULg_X~ZeqG^oZ^OINzN4yGQ9 z`md)HSl^9a1fp~W=|Ck}Kj@RC#4-7qccv#5X?enS<6MfxM358c(|aVNRr89D<91ms z{)q?mSHbt6k&582H`C|2I}HaN^$_jdJ-zYwAgLUI_hYyZMCJ?z2hgK*DJCbS58#D8p*4 z^|R7+=z5Y2!M?ULRWU8^^WnWpV*x=+$Ycs@l`!KHlU4jL8!2yaPL;T!hrRI4j%LnS zpcE{Lr^gX+vqn#*yns(uQN5Khqo=rOpJU~bm`94Vz29E_wHrGWax%tZOa#4|)KARrWGK2R$e~ zjHCDD4MPeiAPWn%6PHs***HWNf2zF&R*5f1vxvu)pdWN;r}6@MKSZ8em={5Eu*C$W zDv=d$Z!Cd~^Dmw<&sDep6k_^hZ?>jJ!&*JU*w%KmlgS7Uw8m`r0 z=}t7a8s$vlPijCL_YLz`dfCsyZSDu)ony%a zXcICC24n$a%%(Wgo&Pan2@ikstN3^QuTMP4S6L`IlX?X=9*Whk zS;JVFIKO8{I;bPJT6vDJKF@&pFhJnb%rTxYkHZoB>C=`M7j->6o3IZ-QEt?a3+8MQAJH99+VkA>95wyl6ae zU++t3O=gmH6*BU*Usm=*wOOO2F%MQT8O18YkiFZv*|oe9Dz56nil&|P?HZkm(3p&k zKhL;F#-!jK$-;bDWv$^}SN~N)RiKqn(bw@R#R=Kp5n?9N?BB`70*Oj{7I!1t0H3}u zp7&R`nRA)2ktHQu8ju+TXxH2`zD(nMbvncKEnUdDnfsTe>Lj+#Y1>%)BkHe+tA>vK z>^_}WkaaQ_$XudZVsL7r!)X{{iQa9A;)oXBPT_uG501H|s`tWKlbKl2ie6@MZBT{{ z1nNF>dMVD;2(l6SFf;tYbab)7k!fzTHblNMD8ROq`rg9OCYyuaP9j~SoSzW{g4&el z0yiGA+7tWw8#u}XC`>Dup9%G4cHe*jK&@*_3F02NE>tiqOV$c9-scf&xm(?tImwPZ zfTnP;_Ev_b9ew1Iw_jq(FHN4VdPJTWvOtRWXLboH<@H3Peyft{1$yFlAvT%w zju(suJWj3%o?wSc@(zF=&M;>Oa)h{P)O*diKe162RfyPRax0TSJQ*#@ zqP6(Mf7-kN3&#vRV$<}Y^m>UKi1^kdV5?nudY z3x#@8SICzH-G1sM2tv=k6pDk$hJS_muu|PP8+IF6UK>J|URVh+Et6oMY@O zoKS#!6S<3FL&A!9<%zVf&%D%s)JkKN**Qi_JHbPh)`^}?l=Hf-J?~932iY+rec;E( zJX^Gq>X4;4k~B}}VIVL?YXfaCo3A`fAlWBXHZND+|J!G~j#%>O4hZ>NKjSR9q+~G?fqY(0#@>V7D;If{sqLc7)$lZX!OIu9rs)tICmy zTQZt&Kq~NwG#6uz2MD8mHm0kK>Iu~Affk_tdt~Ixx^wS75Z6m51@DnHuz2k~%`b-K zn*pu^C5|ouht+?c@xB%~)$Z>z#zLr>R5tz%xsOiQ*_%`o?ht^kY+YlBNs}7LuaYz| zD$V{lrfM4sTFWo;xDjeVMBIh|#TGdAIJ0yRZVzx-wV|$U(txHfyPd@s+kQN!#=DyW8-aW*A`)e{hUDRYd zM+VX(5UG|=!KhgBuf5q8E{TL+15B67eS)UMN?&PNN_2r1#4UmE)l#w3+{08hv7p=% zH=X`v$#~TCPkE{~4sd7rS_$5YcSv}@?j?3)QS;k!o@l75xs4Xq);4>J2uz?yS(brb z=NmQ-1eV7c=BMK1WP*UX>QV{HGv+ohX}6-8a~#k7D>pJCW(a`#D_exniPngVHh|-X zq<&xF;vvxH9rrXtgJ%4bKG5Thgr9@b_k&S>S}Hl+Rw%i!$;3GvTAl0eRzz1$%er3EOC8vuy5#cCIqE zBC)O}={?TE%IWDbjv-(fU=~*riQSnFznkylTy#q0#7H3YiyjPsuV1`tI8}d#aQxTx z<-Gn<>9*V#E-hev2W{ryU_Fh*aT``M>=IvCeFO)SRG~0^NvVjyc6l+tGO>mfa?X|w zR0&-jUO+}F-PO-%*%4D=o5>>iJ+jAm#DBNXkQK<@3+`n=gbpb)N+hWmT-PR3>3#Sm z9y(&)05fk?k~_8nT%w&b;uxvsMN$-k-e@aBarw9_GmVwP?q3a0JX90{XIAaf|@O7xLN&3g6%}ghV`4Id9G1z zM)7mHOg1gb?Td-QuUxG_SU~c+lSUNe1-?}_Z_F-_HYulDzJ#+*`3!xSgJ$Vw0`-aq zFFd~*>8wBSJc^mfB7u3MQW^9lNP1{xWo3KCqvw(!>#eAH^l>J#klz>zIXYOQV)%3) z1eE=^A&xgdpFXF{T}^(nsR^G~u<<1>xH_z%WU1SgZ|jt-k6+oEX$abIwCYE}kOH2+ zdnx|o7B-bo>b)vE&>B7cl5G@WTbcj81PH%Zq&!wO7=jXsUs|L3=?uGMZoNNa`{6*d zNqJnI!|-GZyaik(5yeCFZW%MIM-=?EJw4cdmstiiCxvN1cYrI#hk>UPONvjbehIg? zl$(-kNx=Gay0nJ7IxpqV$aB2(_# zuk-B(-!+A8|G~+C289zo&N-s5)&J&!wUC?4g;p`Hl$^Pp<-_!T52v`T_O(Dbt!9Ji`=WlKkO4vFBsk z^|Lc*Txe3L%n5;u-zrPMsls%4?=;ovAhIOhSsY}vcj3$Uiq0*kVo!87Mvo_DSv%J; zP(&AD_-mz@SbALx>>?`HK9w-zEQ+3`1dOd47RK5H8|w>P{f^udAp%@ymUJ}?lAQaA zk_8hC3i3?ij0R#Zg=D#n-4;6bGl^-JM5@%p=N69qy8bXjDAjTY?XA_je?Ux#63f zEWBRcx@j*am6XZ=&#s`aj58X2{t@rI^(x+1qj22hD0fgEfwjrXR$1_h6=|!~$tV<^ zNoRAcVQtd2O(%)O))#1Wi*I81gpTE0ZWHM3Mh89j2|a8u<3_tB?mD5*70bZaeA=T9~^zfDw} zn9bfhpXgR_kXn>rqFqEB&*JV+Y|Km4-&r&Vt#~3v(*@9O5|C$FHmSbkbQTf-Q>ICx zE1BdMTAmPVzI5gtRj%r)W#Yj$!5wlZqAJWzz~|A+o=F9M-{i(Lyi9QvuMj*o+cE=l zg;_z^DtJ}UTQj0f-ar>4ec=-9(SOiL;Pj8#f9|TkxBLMjXH`a;z>5z*5t>2QizoXJ z>mrl_Azjq%U%dA%Ll?dtukStGX1M;Arra4}vgXPCA$i2*=Y%#~80cU$@2R`pm)#J) zRsQFhgBLS@=SMGVIE){8CS)2ctW-9G^98Nx^QY1^d4I&I^jLe@FTU&h{g=m!bq|5~4DXQc*?flnS^5>{?=7kX9;QI8F7nJHSa{I{vw4ZPd}jZhl1} z5*J%JP;UE^#-&1TQ1NvRoDXHM0-)I-b#P^Bo0IuEFy3A+c}dnR7V!Bh*Uks?svH zsHo^2SyY=iJoLIdw&S^%OjeS=oChl1#2(mD*&B({&$~y2?1Rn#MSc>&JqovhEr}F zEabP&c5<3~iP`e!2cDsFGa+QYkVvxXJ3h%2JabdX%fhzMW*0{^$SEfHsY_zIfBX8qK+h*wS6oKxh4&))`K*Or zs~CpIFPwh5=1cj_9uQ6S7r1N-BktEWD%EBVCm%*A&5(P{*)bqwTD5EvVVu8H%GJfa z(dO7Tvri{lqIvlE$wHfUWIPzq{MuY5x46$%O|$%7sBrd z*I61#v;nqd0sUV|gc-Vka7#MR#!UP@-=sb&ak5k#{Pnh%PMA54Tq^c8$t_W~uUTNE z=<*ow3Hx)fv09vn`XqQ3PdWM_mI?d0t&*m$C{!`YP9)eq*I&}rxnyM>DTDiH7{h~u zK72S^srgK6ulyRV!-3<9GVnT&j>BhJ{n?X}8m6BR@Hi3kE5|J68n8OwmVHXtFnFE0 z7-Gag>`P+|i^y_YIVKJ0%xaA$R*H=o$N0KJhQ?IXX5Y&{RaTJg-+pRz2U28mr~4)m z=%}J+Fh}VY6?@PYJ3R%nVqTcJW50JQD!tBYxyDa?TvfJ`88~bp<*Lvm0JDs#{ln9~ zFok8hLT>qc3*L;tKoJ7x2zzy@EM!djv2sG`xx@@YPMe3v*6yn!mEa8??dpR`J4#vx znTej`0FvkkEB4N}>^VF|1-fgo&NsZ33g2|RsV^33fWR(`_HOPNi6bS$Mhu)Uxo}_g zzq(6$E-E{wiw{7oi3-77e*`$Z&6tvL0hZlBJQhEi(_Al6uHzjyVOAybI}I?kYVp=M zi_14J(UZivuUd`2np-O4eUbQ4frgaK+;)Y)eNzVAwvJqKHc=p(hyJJXB z`BS!F?mp#GfADiS=CyS2-WvFXCSuO|QYPmv9IzVya->(r%I~CbLTJy@3TJpXfN#%4 zC?a}LAkxamf3rT+wkh2cSHi0tcV=Cbw=w}vxf;>Lte`R=G+2p^XOcJlVeJYqpm!AK z3*)7>U>E?Vh0O=rd_rjoZzMGaaFAJCN5Jm(T$=7D<&Zy)i?>x2RPUk#Xk#IF41J6* zxyuVrPQxK*$d#TuOupCC@)El9=SOtHSfHZg#WIov*1?qmrln7?9e%up42 zw7(gpp

frDI!g-DB%@A8VS9^)4^}G^Sk<(aft%;9fdcxWO5VVuuEMTdw!%Txl8wsum()v4*C6W1iNy5f}vSaJnJ0-+4OYM-?BH=rd z*JTI4kt3^p3I@??noByFj%ao3eynOML0I_mO{H?e={U=X+a`kiXr54a^cBf2updKNJkS%O*}BO06276!N~7>M_-oCqYZUuXE8Ez z-IJSTj(q7Z2tIZ@~*bi&lfNw1qUs;J0J2%Il=q5>#)ZC)L*QTE5y_grP?#=4huJ$!(IpqOKx zI_O%$>LsR(!pF*opYtf4TTO(%Pes0taIXFe7BYu${%RdamGPM`(n&!ZLGL>Gep%OT zZh!;3GGw{n@!l_B?2i4>Pz?LA5YQ~>=NCmtWU8yT{-*ulAfyC9Y*TsTw{?-{p=7B2KRvpSrbO)r*# z6$=-)CH$L=kiJp;8}~)jYH2^iQ{Jr`FXAwdraa%@eBP6&t78JQEKX3XW3F}D8UvWe z0D6?%q6Ycm(}k+@%T=6SzF>sH>ve2vs)pV)lV{oJa^S}RrAnRXZ+#!uWrrO?oy2W+ zj4{p%ko|0afJ+hsm(Tln=?+&XFvK3pUpDBY1CP$;pRB8bQflwbFqFq5>d>7^n<~z% zM#(Z39~MSc!h0fxq=jqQIAd%cXWiAv{NgA=pIEQMo_TNkt4Tw0p0~A;x2K0@d8{Vj9@if$M4F z0AGe?tbYIZ?-RTk{hkIi_H{S$&;Sj!?zXHjCNFmf#?;2Sua4STbju87R*t%49CmWF z>Sip}?$spfP*{MhjW>>9*t`x|8DBf}SCm(ct9rLHt91j^_L+zdgF6`O7^mfQQ{pyU~Tm4X3!C67G%mW6b>Q{usWpjFQEJXIlsf4hW z=KYy3**D=fh+&P)`;?U1b1}9X58gg4AGqiNE!qPO>IWy;`THrq9jM~+ZO|Q?U#7pJ zU%m=A-=m!9;l9eq`)!o+oxmRE>-#(N+84rkC5>9MK=`2?DKf`#JW5<(y|dE|3vJf}vyUideaclvJ7 z8H1ko@1L`6x(ZllHevcK7Lo3&=;4x5C+<(SU7P(mJ6@zap2*69<9vTJ09HGGG}BGy zJ8cP9$aQxxJ5^q>dSP;s+4Do|9#BR=+|P(H*7k(hb;Lq<)4S3GvOCx%;90|JO;GBv z1M`}paIJ zb>LN>B#5KMFOhOtQ1J6e zt0yO*9i^0QVfFDAw5TJ*4(TO9o)1L7l?&gf*aN2WZ^&wgNXLpcA6}}K?2VLV4V(8` z5gABDNc%IdOj;Z6EM-=_?2FNQOO9mLPUs-z)tj2I)(c3@pzHS89z2rDmqQSUB8_|s z@{ENPv*K3cUox1FTyDCf&OZ|YyDX^3LE*vD{5!@wiCzfHDw;C@BIxC!BIirFKb8$V zM^1e3{BcxY=+ju4%J-$j0xhNs6(SeQFmN-?(J&6UY0ZEW5p%;bb#gf^;#!!M5kak*F*)`Bm zOeNRYvjEM!^41@;L%c_8;;c)jc+D6LqiVSNGd%#Y?ENSHK_%Np4?)-&E%v#ux|U~k z>f7!e$jpL6;@_}eO4{+X`+7>FGPUUc-Z>0!`OUF$U;S4`=J>O<4OF%QVME12c_<|E zF|Nf~%sGmcMoVhk;P7sllM zuwdwcj0bUy)k8s@BP^C{<%z9ix7db4gmv8w!!^=ZOVRCrR)aTH_gChH^+1>EN$k>IT5YdK{pMIGYQ!* zPNtR`eLuaYSA>X@*n)cBxNCNTnVab%PX5d7C*Kv!XqvIVYzE&1{5qU(sQ$T0X5P*p3mD?GOG8}?qWnM~ z;Z;S_RcgHx%yjNB$ttEYIwcl*`8^6x=}zb967#aH(WI=D0rSxj(}ux4x6qNuoG9|4 zXCZAHBGp06^y!I*Zb<;|X=X{#r0FDnx2t5R(?3#zq&Tar)m$;q&6mA0S8zi{*Dn)m zL8-RdIt1&ksUrA`ttKZoe)@~ckw!ji%6?3%zpwBb*e0v|rNFY$hdg}S8#XM*1Bv_W^z?oHdvR6(iMaAbP9tu!${+)#DL-b zpQ1!AThHl!01)VRB?i2Ujb9PWO;N;3t5sZ-84#;@tYTv&kz@4jzZtyl-*m-UrR;6jZcj*U8fDmoi4#Q8 z^wpc2-WREr4G5#cEG^Wx>P6vPWIF->HnIzFF>@2if|%j^m!-dza!5 z8q?X*QVKIL5v7C~WNB{pbf(>Xjkc^%W1eQWS-)h`QZ~=#2i-lQNXN;98=hgI`jGD6}KxsVl zgX$0g>sQK{%OhU#O!ERkdC@brwW(t}y2(ma8j(-~WY8Nn!Xt%4gDr9tw%KDBzB+Hi z@KDoyUMCpw@I;`O>bP6!Wlb78ul{!BX7Kr-c?}n#@yD%P*;dK=a6U`bqzSBzM+N#L#~Uzm(#S5sn1(CCLO3 zuU^BEp@3g=W6pgc@4N{Dy3GvD6P0~-wYE^$W!#K(gFX# zNWX`ETctIG^>@z}H7IK>>_ArC8E_x$>P@wBHSZI)T@SZ*dYyxrp>FD{ls_iV&dVTUY&A775yf<2P!JKs=q z*$@ha5j}O6p(!c0E`|czi$_78fRV^y%Z+7QAF5~{o_ug63o!7*>>X+7$20m^^yPU| zO=~>+jPovdCwe?P#<$i~BAMvjI=OOv^|fi9^t-t;Q^lo^-r^-?Jr@H~9|}hEY~2qu#L1=O;`+4jFm*4?)h9tWeHQ63?*Y<{lT0zg^0Y-n)~H+j zjMRQ#m&GP0M+9LrEz8uNycfx1wS#*x^rCQ1mfeVf*fYGG9OUx%LRhN9de;L_++G?diF3Y13i^6wInw7=Mi;~1jzn~caiKe|1PTd!AA3i*jwF!os30B7iy#aFw z9gEg-SoRyXpPB?Bx^`b4)b3mW7N)8=3q;@`v>0-PR0G)j+VJNYE}Wf2(UE6gLCLuT`+qn786sAF#g{Z~J&p#KR0q@CmbT?PR-K zl5MD7bS!5gdF}rq?Y-ifXv212UppukdY9fq?@j3tdJ7!|LJ1I%&;&$Zl@@wSAXF&{ z1PDk-0->q&-a{`+?@AX?-_2gX_3gFJ_tDHjW=vZ3;wv*Pm=h^ZJ&!F znK-RXuUPDMUt?~vnvak7ZM~i|bugd8>g`>lq66nYw!P$Tqc0RLTz@62B#5Ji=iAc&4ayoi&MNue5xOk`3>xQcb%sg zdvFD>{QKdFk8o|;< ze!vP{YonO+0~$P@ zd6{49v71fQSSfa-lH2`<LwH|8kcPe7>F@KCp&?R2YG&Dw8R|JZvid;3Yb z>N`O-_s6tDzPH{hBdC~CG5bRR(1D^Zumh4Tmg_Z{rr~!_q_}O{E$p>-+jZCNoRE~S z`P@;v-R(F4N=ss}^k-~hBTU}aow}A{PA0W}{aCAMXo7PhyE?B0+HE({KL^jadT$OJ zT8ZRLke2c*?%$A|X`kq9eqoed-VqikrvFW#Zx*VaHAA1iy4%m*`ykczmms@AJnv6i zeB9BA1*n)whP=?XpY!0u8S9FWSq?Gw!T1(TNb>UsOU63vh9@deK*OP{4{k# z)Jmnn<5rSD2M1-qDKou+@@PZbBuQ+qQ%N||OUd=3JKs+I?DnU8+ewua?1m319 z+ijO~;B9LlZLU<&Jr;9Hn(2drc9LwJapYy-60=6?UF|iQm$1m)jMA9Zb=4}+5={5s zg|}E-cg$Ck@%OA~DsP0YS%HUYj60(2<~VTg-@V(BtC_^VFai#n7)K^C2^t^8x{(@PDLv zBaHn|3NLj zbD<43>!76vPsY@v^6HE?sh(lCqn#oN(5~shUK~a9a+oJc+fV=`@hSJQrAt8RhR~K;W)WYd$CWIhH5>lQ3uImOv;lx+Yu9}Rm$NVqD{8@cD^H`(4n$@ z@u}plR^=bl<@uvb(ieQY3k=@&M^m*kwRII^V<0P+A>e7dubtIYLCgR6Xd6W5sSx13frR>B3!V;?{BG|}3Rl%L&)!gU`* zfKK)Z1<-hN-&kQ;u+U6nmd)p{Xwv=yl*pm5op2K>`z80`Nkb(>-Yv36IuDmc7~4ty zHuf9!#vZZ4w*{+&ofnC&l}d!Y(2fUw>iNU^{Xp>S)$Xh8ZS-BC6#NEXee(m%o}hzo z^!U4ZC$XJ#@mDr&m_O@3WvVqbx}28IwkCr_a_cQf2$K^Z;AGKa>i=H)e>aEBOUjAQ z8Dbk|(MJQR(+8OW%GN!zOnlPbn5&{9i9!5BJ3T;uJF%{$kKVCXR^VV9sXm6sAF3u`NbTw>ctN(#yli%m+6*2$O<~Q!5Z{Luz z9^n`NK>U_>zOsG~)?rgPyq$JJ=T!V?H=XqS(S-Ud&^FB4mr0@N`)-bwy@8`sZXHNT zLXY1o46h{Uz@fgr&LGya;$DX}+6g9+bDP%V0zW(`Jx(Zly~l=?mtXyDO`nkym%9nA z8Wv&Oo=35# zoASR~F1PZt(PX02?l)8h%@1fNIWc0;SPI1wH6>&UEzJ*zc7T$`uSp?&+rCm0QiOPVCM`GRX9l_$PIh1-(eEtEf-bT%Rw^ z-4{7AuJi?sv~*q{e8b+D;Zz{%T;rS|<-J$*vArBRAnd7_W&mbt&Ewl*ca+RQPo|lk zLdotsnRAAfEjm|fr$cyQZxe0ENJLD}`G40eay4EY$a^`4+oi5oXXKlEi))f=k+N`~ zmAQ%>{4);QQC35C$uL*D|?v0tRAw z!7TU8ulhz0i^jC3GAW!;Cq@B~MQrCyg&Jo!r>(dm=++POuq=n*Oi6{Z(e*As{w&iQ z?|j$SlKDr%a_mm5(nF3BmZ<`J3I6E2+(tflQW`^CvyE9v?4?6g61g^$}}B88xK6tWg6YjB1Yy;2-uDoRo_Zp z`T5$D%HF*z)wPq{&(53BzZQyaB#9f%#PbP~ltBoS*XzpFvnWsZrhwC6pAD}Mn18yJ z0|sjK{qlNRIud87(t(_AcdJv`_oGh9E8{+Fz;?WfkCv2YnNLEcjY)g6KylY`NcJ&q z@N>?l1NOgbBkIv(^L%OG5rj!N{00`|HzW;;_sLgVLPI*yVB3<3dr9$+orgK){PF_CHBYDQf6* zI(8A|zkgeK@}_Q~br<+U`C2+ktd#KP5#Bo-pqEgXulTGSdcY8I5?S6GB3{^C>{+X{ zsuTgTM;LGTi+EUU{$}8PpSxxb%JS?K;aDAr1eSQ%Njf;EOP zfp=|57V(&?hykkUC8w)Y)1vxGL_O>}K(^26>vt#jIl|6#J|^k!z#yG9*kY~Ux&n;7%a=Fwh^zM09%PJpS~v4LdS>Pv*V00WeSgXI4#&cu?M+GWMm={> zC|1U%iCJ51Jt!u`65LGfapUTbCtTsaTK6sG`Cx!D4sc%dmBLVzCOuI2!cR=ttM)6Q zd$v4H&&IY5%X4!>U4eZb-?fUV+R;2#oX!7~ZdfPyKziMV11PyJAKRO2B&VXO>b!CT zCTxh;b4QBFwyz&f%$whm$kbqLUBHBzRTMXW@0WTQM4lOL#09-*b^Wt z`gYIfVMiSDsX~1{u%KiEU3$L#$}KV;`KUiUYFqRM2T5iwd-!!xOW&;J$t%#D%Mf^Vwd#;b4x}z^AHCMMylA&; z@{Meq>7J77z6V!`_wY9A7@Fuw?9?swglbM?Qu>nY2_1vIKz)q_#wQNRpo+tI{K`ar zaF>vBeU7_NFa;M>FlF*lesi~Y^0w^isO-F(sc{f_qrK_3QYDOt_G_%lGNOUvbvncg)7-=jaCKlqDC_AG@=*-}xh++HvDfhNzBpPmT6&UOHWmYfW#J1xA4jAVSAz zn-tE!hpSGiOii1)`@c#L8vcvY-?vbNt*U&9J?jp!4He0@$>qs&x}=_=0PpCvW)%fx zhZKf>DjuYxwtdK=_ib8n`M(6YK1DLl6;J=UuGFSbxKKGisRocl4z!B$zUOgz^Y(f?i3hU~qHZd*K=%IHz7v;FvJxA$pQTd(xEZ+#8lk<~-r?AG-r zL+nHa8&)Aec8>acS2bTBK#&nS(_`)!`!O1DiZm-l5no?A}g&xE_|gJbjNrPV=J z2zpfdy~4$*e!E9SR$;wtPKyB;_J6tjN@!Qo#fVBuz$v64Yi9ZEqX6N(iZ?xSJNO?) zxn5ke#yzi&ERZ?}0vras1~d-Qk90gnSgdZ_>_3aOJ${|JU(V{1l60$$&2CiOMDvmv zDs~!Mo?ho*A^41SZmZq>vlUP8zqR)>Ojr?lZBp#xPdX28-eKz^zEh0iO6>Mbf~=Q6 zX1Qratd?O7R8E-^4u|@YcEp0!LLGAgD9v-<#74dn6l|48zOc#4L9?Ck`;OgYZ2J@1 zWsKEA8fB$%4fr(+oRKF$)9e~X{Yu-F&oO_xX-Y--MQzLX)OLMaxS_JdNWGVMk>fG< zN5`#vsn?ISQBzba0+YXPnq&F<(qu&)S39pIwkYwTV-~?$PZp2kZA8Q~(GdC+<*_mh z31MZ@cm?eI;F0{pPQhFKkSmoZ=ITJ!w8Ap_orS{92o1Md;nJobm5_n{R zI@tJ;mwARj`LcLW07{Y4LR;bRZpHx;Imk!;xtK`IjqG3wZ`)qPE}GRm_374E=ITr` z?luMy^%zuh=BM-Oscst%9Ue<6vKhUz4?1mV%x6vR%B9H7Ms8%jBYBmU)?2Kkd_u8R z+!(1&kmcnY3&}sug6FXu?yk$8%lE?M>Z;(9sRpwHFMU6(tiqYW>18DOrqy(ht!;{q zl&t(hc-OJexD(96EAL}@IHLKtd6;hKRm}N#{<@ITkwR4;+{O`2NWf`4j6z2@D}*Sf z=e`>2m79rn=ddbwE~)F7PaeB!y(n~Wn0jp(NzpvXC~LmMkldY(_!4ki-8~1FLaeU` zIix;rOQ(~|R^|Nnz0md#z6|rx8K3k5$)f=qr>O%F(kcJbzYqH_eK7K(My%xn@kJ_n z6Wa>hV54_(!CAUo)fr1cBOp7`x{Ne)-z+)pUln(;y+rA8x1ALuQy)k3>>Q+0r<*>4 zF|%Oh;^RUUYo1|+;6iSAD9ZAm4(8kbhMpP7o|82Ppk|@Il+# zVmj^T=~_AHv!eZJTJNY9KS>6=nc&zcG?tt~=q>B~r1t##o}KpxaUiGTp^M@@f%c78 z{g1TV?o;jf1}HsV^Lak}GWD}T?k{Onn)!-TB}0G?XQDhZ`WMAzA=aW0v+(?ryKgl1 zeXfm`Y^YlIP#Tlm4|qCbuFMdxw2WET&RwIAmHG7O?xDq9x~6_wXH>rO5eH95&g!}w z>i%5O#_k~9c=eG(JP9zj9WE&&UMO;7aU}){%ipV8y-ShF`X&aQY zjPJn0g;tEknm#LBo)+v>3cZMw+N|@`Vtm^zi4$AioF%&^DVOHd^pTarT;Rx|GTJ(+ zMy&-_sr>P&4F?t;%*5n|YhDIYJ8+z+NxA%#`Z24@?5?W_YCde9d9!QfxX5R|2&e1vm6MhqU zE!6=k4zo5`w>2ubn}DXPF)koFrSFkkQ;DA+Km)cO4~ouuv$9|M*?Q>F-BP?^#eL(X z!{UyG)K~vzaAbTgi4|$QSWLP_rh$*(^Eu#x2_;2IMrw)Lq;Ui9&~eQIQyPk_7lKzw7YLZR`gQ$=%~vO9fs>pLE~%UE@jyX&hMsiYG$}`npNvgFqPA z#D}6n;MKOclUu%?7>kC7Y`S z`Dt-T6C~RW9gF1zg))4C5#Gu%6LWl#a$76Rnim+yz_LVv$=Z5a$PaGjP7WaP%NYXe z`!&bYarf8q&-LO=Da*=J?Ucqw6(Itij&gpW6PI3OFxh+e;Yij0Dm$)SyNx=}@{b{t z!uReEXJ?x-u#Tvg)f`I?M3yIdRG4MaaC=m?SIRn+S=62pRBOnG;f( zt4gbDwQ?xk$@5=K?F*tI-j?vv>WkJm7l=Gdj9hhUucHhhgy)<(fm^5vYIW!hkC*eZ z%bc-)WbeKuZuH$c$iS6X`3Q$K+cY#-FvAR-tNyiGQ|PsP{lqR9JzA@7)lEyCZc&#- z(XmieU1^fOal@a<{RFp@mv15OU?}P^X%{?cv0o;`SCk=tVr@HmO1(=_PGC?RpU+`F zV-NKSeSyGt9~aW}8t#RcPma09y*t- zZWByn9Rf_~+%3LMPb??SXu3(WR`+nakKtDyR_~a%PgfWNjXB)>{U8ojjPvB5?fKpDeuM?;@p zg%UHEGBc)6uyOSm|51hJL$Ed7Z=1}rU;R$u9B;a>U`@)Cl{r!$Do)AAT!h$M_tA4- zL1FfJqj%rZ8xpD`b@*b7tqr|lul~Rh8DVPHd)l+l21#{xtU6zQ!P|o2vh%M$bF3hv z&NcGMbZw4>Ss3U6%2_wMthjqi7va1!s8m${UF6Sg~nj%)9&a7&Re4w z=R9%7{%JMd!HXFE_uFDJ@13!({(SaEbRt21c~2d(d~*+wE+$mkt=2L>H0SbzR-rS9 zD10~%m$64Fzfrvy4LsT=t%n@;iq`hr{f(8=C>P7GU>`&h|9Kja$`Rt(2b;-vm{P8R zdHPz0uVl%0&TdDZV#s2&YW)5$Jh;;Dl{iR8^^Vh{lnX@u{yuRb+;D6$BU!|4-2*Zk zY;b%dond0n#a?7}nnL_!i3`4kd>g<2MPPA=95$B5BPPjCONDG}PC(GHU> z-N6ri`QJ5Q{gq<`H@B_+pyI^;Us=1jLK%;r_XTLus#C zGm9Q;KYsn;p)+}lZ}I9Ss24;vXRyi8v4tl-HSjcFu|?4-_OP>wLF_pEE8!0S1_nl6 zK8AVXgB<40BbxaHGJ127HKCqMixUa1%N@RQ1aV`tSSDwln9GEWZT4+QwlvO`BfdhWM07X^)y^7-&adyf#T*XNsR!^k&Xv*zHFOqUHCmJY$6+`u(P8j)$vIJL#ibC?=?6CTdGt!I+@F#r-sI8njhNhHgJ7_xO^y2m( zirdhe`|!cChzwGbrh-9Okn{TQL#+LY+$X$uFkBy)mv7Yg$<44tV0cW_@5m^4;+*94 zc~LAg`+7TI;o_H#kVwO_WtGKb0LxYmUr1J4=983xOa-rz0iGSNf zrx7)LqC3d`1mZ%oT*;g%sUkSL%#Z}xr6}$DNawVi*7YgbusH^1tm5_t=H?tFPuW3J z!Tv33U5-9M>#x!@LY&-_?;*5LC~RG4`j6eZ_lMcv<{FsyAoFFj?`yH^py zof))UQ`{9L0acIv-*f^KCGn#2$TD1`=X!CrvM9M_79P56GQw|K%vg5T(EojTz0CE6Za3~|Go6Mc+TET%))ZFaYfT~ z;JxFcA-vD^gR@vUFCzFY zK|*M|(EOq-6DaAsaB?4B!y;3UGfgwENAo{6kb>6TaUb#>9eCYgCt^ktaq*FxJq;Ao zMTpYn8@>LghO;%8QZq$6R*%vFmpZ5 ze6MQ%M#fd5fFhk3q6*>8o&9+`_2=!Y-``K)`&tgEu-B?Todn;o zgDY>)M@reHfr68gM<-PK)}Cxn$>D=qlWJG=m4`YH?wqJ!y-O zOO{VZwvA6)Bcq3|fbd}x1sTU%Rd=;Q6emamiS9iY>Ey12RRKb|MPz5iv-RuVovli~wdNJH7q8}>zykV?+?3VYxA=YQ9@0<{PF#-j2mN>~3uI**H&HJF3y7_4#;?$)?yXHZLgmaz<;oHzKM^9*s&6n;ywOM3fo z{~Z>el$@L}DpB~@b&ce>-pcjPN5iYC;nYUq1W_5RoLr9`m_08qyS=Vvx05VRy8g~U zu6ZW;2X{d&L#qX@oY?_Q(rs^?)%E`&T))(T3I_3O?_Nd1baU)~DL z$u*;${^{CbL#9ugET1~1+L}~zYgY}?nLIvI{`+nATt`@BX$oxh?ZzQ-B-eN4k++_e zDK+7ZbG@7m=1S1^q)lk4%2@45v=PvhaTLFEr5II3KEj{njcHM(XE9~!+5Xi8iyLGZ zJ_HzJ%Qt6ceXquzU1nEeGKAthA|+q^b{C8A0D;4Tu9n>mqeuZ`EocjWN)HRaosOsv z8M^JGb+;%nM#0)H1W6$BfsUtKX!4Zjzci`J5BXRMZW|0~adwX>;LN}84@q|A@q2vw z+~_SL#ji_VK$j#&R2o3I_951sWAnSs#5-v5$rVG{m5eqvccST=8sZ*8CNWf^8@4ap zxc;i^dRUU}@fa$B9*-uwiE@mhL@jTURJY+08qUgmsO@bwm$su0L%1HJqGPQXGXfPz_QqoE4vjhfK$Zt4uXN~ZwN0)a=gF8|aI8<4zqUH9r+x}fg z8NpPy57{QDo;WL;xlJ*q1tue(ESV;t`^~L-*!W86!W;G00?JMyRor_Pw8KVj2|kW} z@@-_wC!&FWpf9&WWW42kS#sSy-?7=icOkhhXZPR1o48r?HN3toWFN@v-B&td|E#Yc zBvpY_X3UP+Vt4;ryD2-2=Sy)x5(qc+*{Jr zmje%};xZ>O7j#tDUMfF+Cs+7=z;1lEFD_%Kl~mTEc5Zy)lr|kuHWn|zy?8Cn`$fEG?ZK&(zSglvH4ab%t7f}Lp(}ApBwy!v+rCtw~$o|4kLPS+hwdmC-@HO z<-7F7mmT(A;!orI*jFZ-k=Tm0xQW|oIS;3j$2kxzeN;suUa6+xLoF zVNHg%+b#tsj_d0`_ovikm@9^0ig|6e_@gDf&fqWr#*CE?92W{Q0^?zm>l0t1XBIZo z=N2;LKg;|r3%3gxelQDb^UL|@1$LQ`-i6XzXQN7{c**G2a3A}K!VEfHieb*KI>oxN z`lc)ZG=S==yz6CEL?B=m>0o;qx$=&OhB1zTqkcC6^PAK~(v=#>ZRQPxnz#1N-Ezr& z%ZB3WTri^Hkeq#nkxNsG`sILh)`GdzcI*rFtD&&izxp*gb<3xhC&nQ60RZS3vPa8Xrs|nR!WhM+Z=!rZ@0u5ml;W~J4869AH!XK` zQXDc0vs@^pew?^0y|rqhLx)n=UlmSDl^64uzQ4rVqUEgSgX%TUo8jV#DhG>KT4D{t zGV5CR%iQO9Rd=(-VR>=2U!hpg>S;O4ezQn*9lJD`tYQTbWDOPyOWIrK3GjEpHthMjO18zYv6$HNrT<%)+ed?)3vW7=v3PMqy-^*%*PIiE?qH%%;tK= zOOULpsAi!3DhV)u!(sNUGv}YNz6FDg4r&K>Kg~%Bb0=V^+}glH$zZE|Z8M;b7ag4) zHBQHh_%hWCfDidYUO{f#5p!2TD+`S+dzVcMFZT3iB9i4x zTz7+$1<3-Q307`OGZ`S^`#N+Eo%FNcCIV%~wQ@y?t7vl_uOECJ5BRbCS{8UAcaj*e z$e#b7vTDXr(&xsKc}jY)UG7yPf$`I8dcQ;QXjpjX`kesEvKQ+9*cqEIzCA?Ja~ z=80`-_MtN^D`ez0qq4}`%nAU0(JFr!`onU59mRGvr{=^}1ZzanIS;!76j%}AM>LA1 z53GbSkoV80CMhCRM$lS;sXNhn04353rljD?C1Br9yZUSp)!d)&Avm zv2!e}SPGp!FX7HhU%NlODAqO6Wi{H9{8*sOQZdXwiw_#jXYOENNIo=p%jp{wv-Z70 zcbbFvP{oQTJiIHwH|Q*;cK4QOvPSO#zr{WN5S6uYK!_LdE9-Gw4j?Q+FK^@VN& zNK+==pTGY75&SS#{EgKSWBA)d>)ugKzI|k#L0r#Ql{+DVU7tWVk)8nXN({u=dE)q% zmDPaxQy1W7A*+jlRFINy*dN?TMgx|lW2)^Z-Ij>&tetetDGA=WDZ`2gblp{-lX_tLk{qC5ohn8u3UVc)(8wDu$t6VftfDpU5pdros0t`9g6g z4WAZ7BCdZ}V}2d`1Y92F5pfe)&pIj;YsA7KcEKrRVqkFtPGMzT| z%ub>jm&SPKcDavXNiWrWo&z(j7zicY?Uu_i<{u%LbjOD3NqcVE-ObWVxqoM5CZ^V~ zZ}c>>Pm?rix?dG=H4_t=Y1~$TTOl?f`u_KG2K^J26%q8^@DzN@t4W{tVMZ0p)q2{- zVuFn8Tdn%n&Z`CnA9uxM{|>_vqobpV_f*)}MCft8yMr{4c0W7)Q&c$>>M`G2&e12BL+(}nf zbTuh;Ie^GAqk@WTLpqxJ7zl4f7a+`Ur$Z4q3H(Rpix#0TBy}<`Fn%8@T{p~8WSKpzwXiqa+(vs_CrSQ4> zO{xN6>A_`;!Wn@>-exh~#b{X5Dqz(1$nE`^_M=S;2Zs^oC3Yin=_7-kfaLHggjub;g-G~;J6JL3SvKs0f5s0BRLq|kI9{Io* z6)A(WWs&91VGQzH+bsG19 zRbl3`=p0ibnOA29GZ^=H)}1y1$NNq`IAQnm%=~Ok?kcVCJjj%Tbk9M7+`|2N6timS zogaj8MJ9(m-!I#l0iX<7x0~awWe1b~y>;{2M6lkIo`XY0&C|~5BqcBwF4r^fE~DSJ zYWny=gAJ{>{=589?lk$3dsmP$h_{%+pS>4zqa|uZm+G)LH@nS^R#Nc&iA~Hs%yx&` zCXx=kPkZ}|g0l3(AJYdV{qbUW zT(RStJ5>C0NkH_rgES)qv0#7IDuacr}ayV^^0XeA4&?!9-BU?{qLG#=Sv@d zyJkX<6Q*^m+X-zcH@4suFf2V+;%%vrC#N|$7{>rtQlGK}*tX&tC%Nw94oYSD{GPL_Pw1NfZc)nw z^iL}=Id3D3%96IlPH*a1KDck193Szd(?3k$`f$ctQoQqW!}m!y4*!B)R9f~&wBNz6 z@OoOX1ORTVUrrRA%gsb{b@YkJ&iV26IgHm^1qwrxeWNDE!&i2NeFs-=cV_oD1$$%r z=6@vfZR_K6&wlQ@B({iHSbX^{U zEI&^gOoC$dHRtE&qfsJO`j2>w>+A7gehHj#|^$s@AmxXo6DGWe-3yxldUpeu06M z`UdC`%Ui!i`8q4eZccokSS;x~-&aGYW*t5RbMjWsJ2akCWzE`mPtP z)5l?&4x+Wy#DEsN2USo;X4bR4sM)4eQsEkiCWlRNKU_$&OVVpPt+dkA=5TAhrAOpJ*Q@J+p<3KPKxRC1ng7SvYm` z=dMWz4AMTfayNk?4f+(`|S_4O7@^TH47CL7!Sq!(mfsNNIS8_+_t|uPY-Z9KF`|0!yK_LlR1?ibl6Iw5!vb zl(P{ymFLzipaQ2VH{IFS?FPf25GXhGd=VSZ2Lvg{2>P~#e#e67pIe4;C4ZmASIBSA zg)(|;Pb~SfK>hARCjPs&n>VT+F*BQ@=&fZss`^=aNCsQ0tu{~-qNc_7l%sd?JK%0n z-=hWvY`k+|9~EnC8S_Py&k{{Zm|y%vde4NW=ne5;fP;)QRkz4Hwn9mRGp8_k>X$OG1$W7Rx&=F6mE3u z4x)ume0B^Aw&TAAGfEQ9#0dinw04Od(F$|Gx$uMst^J9l9lsivXUc3d*iV*JzS9$I zr$ygq`h~$;bTLmOx)TRrAw4@cR4jerqliIDMA87rvFJP!`a%=cTq3eKn~o8&g)t;V zut)g6{@?rRjibUC?@I=+Q2+HtqJ~-vEKXgqH>a!CF(9VA>TRx}v(sWRF6}n`wkg+# zd8e{{VIy12#iE0ytDc=`67r*$qs#M>Ya=yuC-N69!@197#B%Mj>f2;D{ao|F1_H(j zz!E?InJuv#=Ph2qZAA~ipy1bVkfG}Z=jnlx55$tusOr!{p}AGCLbLtJmZ{-I&2}O& z^{f(L3y6%`qP})E9iBb}n=n64^s=`7mT=8Q6fp-aQ_9mQt?W4(>y^@a>y-U-=dI@= zZ<5?H%ap!-FzN*8?sce<$kLSOh|Z3A@eg-mSM$~M()usCkA{7n0QL-Dip~1wCg9M)#$HGO+{qftlb>l%j$1T zP(R5NAf;UPWIx$w*lP3VCf2PIPpS4{s8j*kc6jKdD_7sS`g?O)l*JD(l@8ey%oB1g z^j_p>x1i#mMH-190H{3*eXwXCVlJ&x>7Wrl*hm4-z4Wt=AyCTxDC+g6KrZht59q&B z0qQ1Le&w!`(psQXcx30bo2n)!Z~pz~kpy1}sIi4}ceB=p`5R#^!ANpE$n))$5_V6+ zP8w?T2h;olUSTO3@eIX&6*ySyMpnJGO0qQINYP)(09 zjHq7tQUCV=>D(OTG|P>qQO>K?VOemPrlU6yu;?p(j;eDb04Tt4lwmx;uE%BI1b1d< zZvh`)h!lmAM()^O%8_EXCA3-kB|NKyqgKv0B;0>hW&%22lVgH;xCvPLmg+!W)tG7C z5}EhJ;grP0vU0I09^Y@gXsARC6vH&XoL`b}DPHU4BhNJcdg@`lii4&KAo;kbp7Ya4 zoe$8eG>q5W(;&5;?Z?ZBVnnH9W>YzoJ`Fm#~UEuKjNM z`}gAa=N~`*-CS`+qU7_oE(CLzmtB}Jm6(vS=QTKej#g5G z;bx9l*^46jWx~UqAQc?XUsUq%CSrRVG3BoUG<8U9mf`|`7rM``hM@tAXFFGsOn*Pu z1S?pc+^+az_;*A|-F*BXd*}Wf1Pm&*LT;xb9{7Ut)(&a zA=wD=%qLd_91~f61*Zh-sy@ujTQWxW zNiB?_IAu(x6s;nss`Fjo6B@MWm)%T()|});aZD26YV2#ZQT^hbGM-l_=uhQ-*?-q; zm?iWYe62m^IQ?JJ=X2Z{^@zy$KU$COSmK(3D~jm51JQ+zOrUX64|+&n@w$? zN0GUQ`uSzGhkRT=_HFFR3^=IE)9-F|hC@=(G3$}~H5A#jBd*Np+1_gkkr0I3fD94#H*D0$T*i+0#)kanQ-6qu{9zBJ- zi_VlckhCj4VT)S4S85L!P~gI%TlGE#Yg3n6OKsJC&^yqg_R6}(@Rhk$z4N(*`(*OL zD#spW{JsLVOWLQQl7S?uAx4omAEaYx=_tk4D|GVwngkDm;SsU)ZymcijR?8pl&0#p zpbN{Z2isyiLCW1(TDY=rjE7#H{a7$QDQmuddj?hNn^d&GxstE6rrJ}Z*uQ)ayp!3V zNSvJ{O_=_uf4PUmO~H&>4mVdGty{+5?MD<`Mid4vx73KiE8RC&=edZbrPgJs7w6Ab}3~ zvf`@smBw07;2+X!nRQ+woz?DVD=|wa6dMB*WwUSbZiP0`nJ3pJXIL_ zmWB6=u)kFGu{HB&6YqO^@a?K?HVcbJbs_nvN8u&=LhUk!aO4gqNyyM$4> z&Qa*zQdPT`J5DS}`{;*JzR$UCOKYxcGaDOW5$Fd^Z*TXFHBDYSwjz=MWWWxaStXg~ zSc+jgT~K}1<4uwxLu_xpYKe)m4lo#z?OI?P(@oU_kf zd)3}+Bg)xh^M(#`-wX*jkMCb_#BZ$JG~I6=v1{H;Ygyisg&)w}hQv&&IiB3#gjK(O zi@Sb`0#PQA)Sod)N$d#&o4&a%ZGvvZE*8cSDPFyF9Sy=-^~@zj-f;2`=`@-$bVt?* zN3NG@aaTNHTTKj#j$4w=tCcLALa(A$v#%m&p%N&m-*H!tmw%=KhCF9*TZX8ev!nzx z1!Y;dR#wL`=|}YZqf(?LsimW!mUTkDR3U0(<$9U6PEdiq4c#15S?{x&Ttb1arK7x8 zI6==fH{U*^eXQ~{-l;`sKi}ofdb!p#*0AT!%`x)_p}<|K7(b3~ z)1o}o5Y`J%-DnNmgMj8<1Uk2mDZc>CRo||qu!gcf8GXYDsfsJx)OX+-2>%K)oG&|t znD9-g#m${)&FG&NHPyZ8kS?yaNf@#Mjn{64`!GNYFj7kzj~Lz<)O#K*C~h68y*S;6 z)gspPLy_v<{}@)Tjhl~qn^>G-LYT~fAbOfXlN$3th1BSQuk8SJ9}8IXL;ifcb_Tj^wU+kRaZB& za5k5yj79)AO9vcjJIh7;1+9 zD;@iDS@hD?8A{sW;(|HP#ko)6aCGi9SMc(P6SMyQ61IZ5n3PfA4lC@?z-)pbl;2_7 zV1}u@#l%>qig$uCd_8?^8zdUJJQiwdOMFm8=IOYE`9v;xRS8IVZ*V$-)}IVagB_Ud zs_)K(NQ%prN6OFJp-pa8izxQLYhK*Gvsf%kR#q{prhB5D_JT^XY{pfyz40x}i-EV6 zhM@7yAqg??rXojk&G>W0SxpgU#u)WDRrEgAr)caQawU*AVW^_P_`{{~3ot|0d zM|u}294(>_!}2kXc~iY?0R~pxA`Cc> z33-Ex+3QJdBUBmXF~G4TJ~!|p-ndL^CZ<5UT#U-F?wBaLK~iZqVwuwxXDBkLC7o=d z6Nc?PgXM|I@tQkfw){*5x3Ug{10VxBv(S}WpNCDtnEgW(79Hv!vtf^RX5n;j$1uz9 z0G&E@(v(KExaxQYvjRFTQt_N-JJygNQ-wZa1DcE+Up+xGP{f#Rb=F*bm&i6V;80xpSj#+DrqC z`rP!HI*OubAD$AC4qew9$1(Q!vc*yxCjHg%fEhO|WEXFn0mo^xl3r=@k<%~xCdxX; zOBt(Qi>;>9O-{9h+1{;oF+jOqOAFL{w)OVxysoJLkYf)>|a`up}{17?H_(oA{-QIdN37xB}R=P;c7{2e|CMG4D zlbGB0Fd*-Y$p(Gev--|`Qm!Ovo;rO)q_;V@zRar;WG^|K4vl06aP>GN+wAkcoNkNK^ zR0ZxF9p8+vol}02zB#clE0R39Mb6DuwI#t8G-aE}G%`vl0|W!r;@Ka?O*sgRCmc0s z$9*+B(q3rVDuFJ*K;hQ4_L6S$`VO-a;XJlg9VC;h{IbdEACw`Jd074BFc2XjFn%yf zu`AcXUe5yx8LMT23*crmg~a3P?tfe&GI0wc?@Btl{gTXT5V2u&Qod*q@2q>@u1#pi z01vVy;!(yfs z>i5^SyG-R`jRh=enQ;|yrA-6tx+iWGA@|u-1kaQz&3@K5`814e4|RWVm(Ih*^H7r5 zgm2?5z)C6*6RaW#CR$DU0(_;XoATM2QixBXXVG0l$tmoP#Y`e`-=c>Hv~p{@T)K30 ztk?;=NIxY5at;5*3}Q5~O#N5-!p9 zR!#_z;JvDue&Cc%-z_UU8`7B3bwnUg=2QXSbtWtG5~>kV-fjx%K+CuJq^Tw_>6D6m zmfe`4)=nQWbi2&9q{qVTIS&)_a+&QLvK=QLcCk3$%$Om&t-*{TH#L5(DlvF}lIbX4w zWQrm>MI4FPt+;UP4RfEZl~TM|zn)f_RrihjM336OL9U0@5yGeC4cxLni~*dCkDC$E zu%Qj_BYeN~7VF{>wz~Rx5dVHxiMn^4I&?zc=!PMb4F&<10M!qU+5{t#QnuR5U+7es z4-I-+LBC+@_zw8r4X7FmyW79T>Ch`FE|J7l9QTg6xl+PKq-!q`$}IQL9S!`^*&+mN zw2BYf!ak!S$Wv&cj$=V%0`e8Tm4yq9@AZjsKP{tORIF;tb(fC!J|r=NoM_E~w4u!R z#3RI@_uTn1t>U5-r=O0DE2Ue;1e?O(DM6+VUeCJl+E1?|RDNjY^EY5tmEmGHr=r{I zdNF|V@(Fsq)x)Y~@9_IE68CqiRXtoLMe1mn3kOg)4{6Npy`JXz=kt@KB{_^HcXY*Q z#F9U`N4PvboSvR;a>&X=SI=O47b7nqS8^x)K|+j~u@sf=_HsTq zEoQKaGx&I?-wk5t!6hFu=$NmGTwWY+CYGg^uDYCjq7uPW*Jm5{mNaS*k0SnsaLyN0 zh6yIm^IQER#5n{+Zyjl2mF>@I)rOW~sFo0V-+FJxxPR=(y7Fg;sIaPcGt zSEDd_N|B_J4`h77dWhK2c`8SOFup!Zw0|?`==k%5`EnO!NcL1Mx4+E`sR+-H4PC;z z)Aj`|NXB|m0=;OC(;qQL&(ByDx($6@NBydUlGE4iD3ZBKND%93l;0cCt6?6l#F>wUTE#hzScop!iF z>Y-}Fv%$JvwJY-BDJt((HCtbIw+OT5P!Dj4xI=K~USl}9`kZV0m|S$!f~YXNu9OuA9fl5fCSTrlR+G` z`mM7o3dnWY2S`%sq$ps|k7t~o5;xP;W+VB9=QZ@NV)Q#2ND+wO=e&kH9M4Qw_uA`f zZtB_!H5{b8t}{`r5>u6&%8-gCOb_7oiXP}3WS0kJ7`ff>V+#Vg1MgOh5DsgmuWI2M z1yDt^YaIq+v^Lslat78j)v^gR_mM(J&h4Sk#Ld0#hH#I(xu*l`z}|?d90l5az}2Z6 z-q!Mh>P5qPR>R`-fF^HpSbA$ZWmCuXfhG2m4kTVr(PSUUspG1d7WJ|TJ1-L%N#1(E z4>ppFk#nx=2x0EGWvwc$n_;b*)DV-K8SgW+wfmr+r&KUu zdS2w>xs0W?jHSjWNlL!9ZuHp3TW6rX?h#^rdi18k0b@M5lif*eO;e#bkCyXWe3`rI z?sh~?+%*S{6dq(^*w>;Rq}c{AT{=I{^ZBk24;Hg&+jvsH%B^-0(FEJt)GESp&DZ!( z7-5MEW(BJENaWd@rVSBw*RY=e1nI>)C*R_7feAZxHgAUK@>8g9M=ni-{pbN zTQ%e6%-*Mvy}sR)Fr$R`bY_sp&mo9JaR(*+gXOY9mJ*d)Sk%I>c6B+(816|jOjVk$LI4*LFUYmm zTs${v)qPiYPtI(u%sRk4_;W0g0l}&ve~d|jWZk5p=l)w^LL-+6L&Qxx2nOr_um=F>Al0P7nnmeYZ`ec;dyD#?cz4sg!(MH z<(VO!T_;E}n;M+eeNZ!DPI@HhJ77saJ?l(~KB2ZfIgj*dm}ya2x})=_zH$=w=zJ7N z9JT2ucgeV7IU7}vt{XOLiJ*A(1#)nn0KX`E^Ms}X)JIM(k+u3JWpNE$U|{ zFp=Jk5!6oJF~5rQ%=h0!78^V%rMSL4%$3h()(ef`rLz;;fTltAYui5=F;kLTOGL~! zT7?yw&1^u8#8OkKcyp_TipIL}v%NCRzDoEQuOwIBHSp7G0ZVno@=A)gx8FY^84odEz?)`z|yZ+K7R)!d-Z`y5%ckXo}|^pxDM z`_^5ERz8G~owH}QZx^>Ka_6R&6ium>wKG^e_pMCm#88or(cs zoyyBUp1?AfdD1C)naqZp8?!RMo4h|q?7{Lkczi}xG0_ICF&}!cX>HV(sxo=bh*nN^ z^IjD>4T@0XPW)+Ez zG>dq6+rt8`I-vk@wQ+ciJYg)~U9YIN>T>2qBEDu`p<6Jd&>N-;4nW)zB?$eL{=^gr zTM|IyO>K!PCyc8YqXsd&oz>ZCR)pjg>gwV8RWCxKDFLi+8&w}ytaE$hPy4|s67|eN z$W#j+CX8^*fp@df!x)S8p-ezSXx;Eu3D6$yisSZ2xBQPkUg-ErY#ev5%jrR$D#aI# z;cbB+H$|+6T_(DAxFdFD(H#}CYV3LhzclrBior_dB_bBFIfA{dk6xmFSvfk}9sB$v z*83d*Ixjo&af`9IM}o#MX}>Rj!;UuOZP!EeL$Wj6mjn%0FZmZ7^QpcI74g*N`^y;b zd96QrXo9lea2X4Tl($VDoNw53V?7Y@(@cyqn=)F~sZ%w=n^k~dU z!V@q2hGxCi(W3W`f3V9*wt_PqU^4;`J8JatlT!>QBaPz#?CvIPxJKt>&k=!z4BcBT?<@qLz2>taafb! z8AW9w9Sa`sP8IK`(u${7suyRTGHlCz8r>Y(t6pH17 z>ZBvRh9bG2WKW!i2HYMB*ORCz2jqTs z;f?7XSVOCChKEHU7?-Ljp7drx+b zw(?IRBTlj+tUH~Pt4EkS3O6$v!5WoQ*y!C{GdQp^3=&vGOGqDg%9L3Usl1*!=IWx1 z;bWVsUb1D#4IJ9eLvkINbRa#%D{Wn#$(ShDP9^cinuVHi&<-piH*`6HehLbL0Kq5P z49Q>~mxj&pdPD2Eyy0n8v3kQAoe7$vpb`8qdc@^ujCByv4TJ%&c7z z;qY6>LTd$ZYi|+W>H07#+nKdsV>(vnu~knx zB3i7CBHg?fR0gN|aT&!(R@(`!>fEWiv1MwQ*}%a5M_(b05m~Up(HLMH2BsO+KJ!i1 z4rTVw+pVeJ5y~5tij2(z=Eln`681=_)hL8(RG(r{wV|0q8>=E|7TUC=D89dK`wq3k z?tWS)GmfVBZHDY1X2An(wPI|}{^L^)^_ChFRUzYOMV&2nCK;B}7N^v$*(EkBY{|qB z>M<03jjFs)lJW$@Gut(-$W?*kn_B4(W;L*}Gr;S({!iJitDOr8F-U-qHB*U5Q?C9i zIzePHuw?xXccC^a`IQ*+Wv=C5(ILMKVW7@O%|>vj3w=j455Dcb#?Ij;Ty+&XQdxvo zPa`#z1OTj)+1hn`#n*DH!j7KNTZWiOr|~*(q&%A9gt$L0`t)^k-MAhdS*9W{(<+&o zI>!Z8!qPbiFLRmo?Q7*fh+Zk(T?Tk{NxQ5s@kn}Sixq*AQr_{@!m8rl)7#s~0`cXx z^EbirataU35C(o6j4glr)rRkt0R`vbctVLjmS=5cYj^Gt{HgNc~4DP)LxRbFupHE_rH&K=dP0)aX2dIUcsDdM^_f_xk zzH*0r2hi$MXw--C<{#h;AWEy)RVt>A>CAuh@Kmn7{zC2yt=qt1fGD{34&^{ns)$Ka zzD74U1NDw`r~>Gji!B$>f}lRfQNBxUjLjaaYrLRNj4vZ(s#1D|ZA+a3NOFH`Zlu_4 zO5-Vgf@BIL*^pA>ZccTUqK?&l4LELStc2Ah%z8NAk}Ir~yTx8zbzn?D4_ezacA(s_ zx$jdn*g*E6u2g|wL&V%1w@2p<4NJvgK_X)%OlUP3AL`ARv{LnT6PO?Jz9HgJV|b2G zKs*kjwf9Uj8uMZ4VKqOU@usVb1ZUeE6$>!lsdZ#Wj74I{B*xhyHU}D?m;>O| z5e>5^IInKs^?B(5?P#z)(FJeNdUiF8(_y>0QN3C(DJ2x#takpmV@peL_;!{wBl zmSSlb`*)ja5OLqvyOU(7eU1Uz{Jj;2oX=Nfc^tNHH5VTtg$tX?rh-yO4Xn^U_FC(H zCNyoP3p=iQE_fTl-_WR|eSLy%R5jtEyFN~fevegoDp7S&-d zB0{5oc7C);!a(bGgKd>>J~WpMQqmdv#MAYh`@ZRMq^R$G5rG|CES!MTd{@=GF`hBu zRb6NA?v);cmllkoX+D;8WPS&rIq6T_R4Vs+wL|{$;<8pVW0qw)T5ks^XXSv!(dOmZ zn-i3(sPid(RIeIqYF@Lbo5d?BlRKVsK2_qL88ygQLaXVc=Z77kRw?o4GgNa(U~b`( z2P!&`gk@j@VL~Cw>|a@-tTAa2lmy)bJzI)GyVFvbLUtT?{EJ}u5~zIk zR8_rHF&}ATfK?rRj5*t|zyw_vwFE?}8BxYt*Nn+5VK`w1sQwt|@IRi9;Y{AGN+gri zC**!_(%MVeOs=eb{&0&TdqNqfCFM_yBs}ED=ydgN6aMAc(^9o=4{EWPJTv8>X0x#|c5K7d?*CE2_7$QM;w zU8#V^^z|^P3ha`(uvqJ$?J+h~sHNRAaGT|=@JSN1aEdjWu{N6SXbx&)8&wFgl`~&d zp#>_|B`UqDd6C6NX4!~dK>NJBxgnNIpJ^x$LUBzOD!&kAv}Uf>uJZ=PsNZC`Z!Q|8tvl1nY)tBv% zaTiZao-Te=wZ#U|)<;F$G~~)G9gOoOnU`d(j46)TCKd)AY zT)P49=gjH0FD6nph}*i>);7PZj@V($`yQgZPn?Y|-2eGz@1XouE9cWu@MT~E1$B7W z_l@4MYZjwg2jmid`7`y8GtT_1Z`v*AbEu^t9-WR9|4#--r_Pj>me>!1MRgv2O)6J- z+-pK=^p@~et2xUEIk(QQEdF`tL-w`!R&j9^A|+?mfNj7#Yqgo zI~Ij!AcJK!?X6D%6>9s)Zs_X08IFUJE-PYffMT37BlL6+L%+>dg3%d-j>kGPEz1Y{ zuYoTx0I$F9-mbRzNS6FqBg8mp74vQAXKW@sg_d;e5AB|E)2g zzzR%I-A6(RvXf5!?%5IC!NOK*T`bS0FMbjZbR-*oZF)y-mp3NYQdvW>KHg}3w{@g+t3-|zQIl=K zTUWqZS--RGVNHIY2U1E;W$iEvMf|IoM9ubU9LU$`-UlW9LF|IBN;rD?3k|!*B#NV) zanz**G_l|Boh6A|^&K$<(wOu3se_vHRgor3fSsYBXR3(~U`>^9rFHzm9&w zjQ!}3SA30*`Q(7do60QcS&{u;hn>pbt&FP+2FPqnR*ymSLmRCpCD=3#>%wz2c(SpPBZ<9j^vAN+k(&uCK9WWR{ zP;rU#7lh-_yk{&mU^k!TcKYglUo&yE6Ip#BB@)tx>q`rEFOhfnOh&wy@T0%zE|0|O zDo1@V~z8m_H7(ZoR^5GMxgS#4dri!ICN^KPtTuv zBLcpX61z_X{TCAJ?y%DnA)9 zFMmZB$1G_NA>w-B;kUGVMeHzfU<=pM8uxbZtH1(AE@9taIb0Dg^UBK*`a5#}%Sid( zD?0s+3vOM(Io9>H1MvvPV#zB~cRf>LkJjgd`Ux3ojhM#0>oVsA_p&QI@BfV)ae5hy zYzK#25xN_8zv3dWn{5$F^E+VcOz3*erODz;ko7xk074UA8YX@$S2p|p4f1x2D>?vNtu#@ZUcEKf@=}7rn3@kCD1mDtE#8kX{?Lv; z@kRJ0=)IKJJ;CWMDshngNWu|aSEPZ z6}sPW2K=nXt}3|SU>|drqtNPgZEvDRHD3zddrt90FBHeMS1s2bCg3Su!oPC(zf4d4 z#0WT@dCYq=bM%Bar|62%VR&EBVh1W0+U+OWKTl#9{x<{gQ?vYkW&nP|zO)yqYp+P% zZ)FD{2YS30Mdy@0-&&!S0~`qsaA!%|IXLkBt))r+yI_m7-0#HiImx%5ZtBky(B0Lq;QhEcegwfE1Ya8;u{`<}_kX>#i#EAxE?$+n3xun{RVDVbdb^7CCw#?R z{9iBa|BsgzkCEl9T@YR$qn?}MfQS0DK{;T22OT!zZK)P9R(@z$;+Ffu!E^^C=5BmK zMUe~GT&pKkTFISA;K;$A)fGvIGSF##hHCjzxN*BPw$bunC2ZaQ0^`~=Cw7_V1^ESr zuS+;mNyY`a$OXo~@B%+{ zwbHb|wJMJyRSt?DmzkVZA*L5Jn`aN7zc%*vqA4jFs$AJ9n=8F^sUb#9 z@%1A8gT=NcUy+6X^!xfxzrddkr05R^63_MG717(=OsIOmQGFVkBueBEDWG*S?cfZb z8E1wg)=8Uv9&h_{ZT|S*Hp2b0efuAb(UU>4d)VLs>lOlO{t@jk{BMW5-%Ot|dnSIi z-Rosb9GJJOTMcw>nocv=9EMG_fE* zKYgA50u}dw+o+ZFs;dmIN z({%%+RRag;Uh&(VT?_OYV`5(=>L_%+FR|KzTFYaz$1QZ~eg~|#HCXIi*u4K8P3C_j zSC~wN90a>Q^$)fPpT%C7b;PlPX0r({BCZuvv4e*i%i!%Im|fWl;W?tdC`F0Un{&>< zofG0eCIl3eYA041xqW6Zi)Bb5|kk_z_J28IcuA8ZF`T@{Q5H1QiAUu%%DmQ zQnNgG0e@C-K3VP=R{b3?gk$nQvqdhWLwLcL4I&q*+jF$mpgW;J$<9AKG(NNG;Ki&p zL$EVh~!CKot`o5s6VZvUP&mSr|$fJ$8mVni Date: Sat, 13 Sep 2025 19:51:09 +0900 Subject: [PATCH 451/527] =?UTF-8?q?Revert=20"Revert=20"[Feature]=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/scriptopia/demo/config/WebConfig.java | 21 ++++ .../demo/controller/SharedGameController.java | 17 +++ .../demo/controller/TagDefController.java | 29 ----- .../UserCharacterImgController.java | 32 ++++-- .../UserCharacterImgResponse.java | 9 ++ .../scriptopia/demo/exception/ErrorCode.java | 1 + .../UserCharacterImgRepository.java | 7 ++ .../demo/service/UserCharacterImgService.java | 103 +++++++++++++++--- src/main/resources/application.yml | 5 +- .../93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg | Bin 0 -> 382330 bytes 10 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/config/WebConfig.java delete mode 100644 src/main/java/com/scriptopia/demo/controller/TagDefController.java create mode 100644 src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java create mode 100644 uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg diff --git a/src/main/java/com/scriptopia/demo/config/WebConfig.java b/src/main/java/com/scriptopia/demo/config/WebConfig.java new file mode 100644 index 00000000..fb9a1d5b --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Value("${image-dir}") + private String imageDir; + + @Value("${image-url-prefix:/images}") + private String imageUrlPrefix; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler(imageUrlPrefix + "/**") + .addResourceLocations("file:" + imageDir); + } +} diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index efdc9709..97ac9eaf 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -2,11 +2,14 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; +import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; +import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; +import com.scriptopia.demo.service.TagDefService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +25,7 @@ public class SharedGameController { private final SharedGameService sharedGameService; private final SharedGameFavoriteService sharedGameFavoriteService; + private final TagDefService tagDefService; /* 게임 공유 -> 게임 공유하기 @@ -97,4 +101,17 @@ public ResponseEntity getMySharedGames(Authentication authentication) { return sharedGameService.getMySharedGames(userId); } + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @PostMapping("/tags") + public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { + + return tagDefService.addTagName(req); + } + + @PreAuthorize("hasAnyAuthority('ADMIN')") + @DeleteMapping("/tags") + public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { + return tagDefService.removeTagName(req); + } } diff --git a/src/main/java/com/scriptopia/demo/controller/TagDefController.java b/src/main/java/com/scriptopia/demo/controller/TagDefController.java deleted file mode 100644 index ae830c69..00000000 --- a/src/main/java/com/scriptopia/demo/controller/TagDefController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; -import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; -import com.scriptopia.demo.service.TagDefService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -@RestController("/shared-games/tags") -@RequiredArgsConstructor -public class TagDefController { - private final TagDefService tagDefService; - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @PostMapping - public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { - - return tagDefService.addTagName(req); - } - - @PreAuthorize("hasAnyAuthority('ADMIN')") - @DeleteMapping - public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { - return tagDefService.removeTagName(req); - } -} diff --git a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java index 824d1c37..b1243165 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java @@ -3,23 +3,41 @@ import com.scriptopia.demo.service.UserCharacterImgService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @RestController -@RequestMapping("/user/img") +@RequestMapping("/user/me") @RequiredArgsConstructor public class UserCharacterImgController { private final UserCharacterImgService userCharacterImgService; - @PostMapping("/save") - public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { + /* + 등록할 수 있는 이미지 저장 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/save/img") + public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { Long userId = Long.valueOf(authentication.getName()); return userCharacterImgService.saveCharacterImg(userId, file); } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/profile-images/url") + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.saveUserCharacterImg(userId, url); + } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @GetMapping("/images") + public ResponseEntity getUserCharacterImgs(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.getUserCharacterImg(userId); + } } diff --git a/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java b/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java new file mode 100644 index 00000000..e361f732 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/usercharacterimg/UserCharacterImgResponse.java @@ -0,0 +1,9 @@ +package com.scriptopia.demo.dto.usercharacterimg; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +public class UserCharacterImgResponse { + private String imgUrl; +} diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 1d676a8c..f229996e 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -41,6 +41,7 @@ public enum ErrorCode { E_400_INVALID_NPC_RANK("E400027", "잘못된 NPC 랭크입니다.", HttpStatus.BAD_REQUEST), E_400_INVALID_ENUM_TYPE("E400028","요청 값이 잘못되었습니다. (Enum 타입 확인 필요)",HttpStatus.BAD_REQUEST), E_400_TAG_DUPLICATED("E400029", "중복된 태그입니다.", HttpStatus.BAD_REQUEST), + E_400_IMAGE_URL_ERROR("E40030", "요청값이 잘못되었습니다.", HttpStatus.BAD_REQUEST), //401 Unauthorized E_401("401000", "인증되지 않은 요청입니다. (토큰 없음, 만료, 잘못됨)",HttpStatus.UNAUTHORIZED), diff --git a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java index d618a697..68d357e1 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserCharacterImgRepository.java @@ -4,5 +4,12 @@ import com.scriptopia.demo.domain.UserCharacterImg; import org.springframework.data.jpa.repository.JpaRepository; +import javax.swing.text.html.Option; +import java.util.List; +import java.util.Optional; + public interface UserCharacterImgRepository extends JpaRepository { + Optional findByUserIdAndImgUrl(Long userId, String imgUrl); + boolean existsByUserIdAndImgUrl(Long userId, String imgUrl); + List findAllByUserId(Long userId); } diff --git a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java index 4d90bec2..3bc09286 100644 --- a/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java +++ b/src/main/java/com/scriptopia/demo/service/UserCharacterImgService.java @@ -2,17 +2,23 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserCharacterImg; +import com.scriptopia.demo.dto.usercharacterimg.UserCharacterImgResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.UserCharacterImgRepository; import com.scriptopia.demo.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @Service @@ -21,37 +27,100 @@ public class UserCharacterImgService { private final UserCharacterImgRepository userCharacterImgRepository; private final UserRepository userRepository; + @Value("${image-dir}") + private String imageDir; + + @Value("${image-url-prefix:/images}") + private String imageUrlPrefix; + @Transactional public ResponseEntity saveCharacterImg(Long userId, MultipartFile file) { - if(file.isEmpty()) { + String url = storeUserImage(userId, file); + return ResponseEntity.ok(url); + } + + @Transactional + public ResponseEntity saveUserCharacterImg(Long userId, String imageUrl) { + if(imageUrl == null || imageUrl.isEmpty()) { + throw new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + UserCharacterImg src = userCharacterImgRepository.findByUserIdAndImgUrl(userId, imageUrl) + .orElseThrow(() -> new CustomException(ErrorCode.E_400_IMAGE_URL_ERROR)); + + user.setProfileImgUrl(src.getImgUrl()); + userRepository.save(user); + + return ResponseEntity.ok(user.getProfileImgUrl()); + } + + public ResponseEntity getUserCharacterImg(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + List img = userCharacterImgRepository.findAllByUserId(user.getId()); + + List dto = new ArrayList<>(); + for(UserCharacterImg imgItem : img) { + UserCharacterImgResponse imgdto = new UserCharacterImgResponse(); + imgdto.setImgUrl(imgItem.getImgUrl()); + dto.add(imgdto); + } + + return ResponseEntity.ok(dto); + } + + private String storeUserImage(Long userId, MultipartFile file) { + if (file == null || file.isEmpty()) { throw new CustomException(ErrorCode.E_400_EMPTY_FILE); } + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new CustomException(ErrorCode.E_400_EMPTY_FILE); // 이미지 외 업로드 차단 + } + User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - try { - String tmpDir = System.getProperty("java.io.tmpdir"); + // 파일명/경로 생성 + String ext = getExtension(file.getOriginalFilename(), contentType); + String saveName = UUID.randomUUID() + ext; - String originalFilename = file.getOriginalFilename(); - String ext = ""; - if (originalFilename != null && originalFilename.contains(".")) { - ext = originalFilename.substring(originalFilename.lastIndexOf(".")); - } - String saveName = UUID.randomUUID() + ext; + // 사용자별 하위 폴더(예: {imageDir}/character/{userId}/) + Path dir = Paths.get(imageDir, "character", String.valueOf(userId)) + .toAbsolutePath().normalize(); + Path dest = dir.resolve(saveName); - File savefile = new File(tmpDir, saveName); - file.transferTo(savefile); + try { + Files.createDirectories(dir); // 디렉터리 없으면 생성 + file.transferTo(dest.toFile()); // 파일 저장 - UserCharacterImg userCharacterImg = new UserCharacterImg(); - userCharacterImg.setUser(user); - userCharacterImg.setImgUrl(savefile.getAbsolutePath()); + // 정적 매핑 기준 공개 URL 생성: /images/character/{userId}/{uuid}.png + String publicUrl = String.format("%s/character/%d/%s", imageUrlPrefix, userId, saveName); - userCharacterImgRepository.save(userCharacterImg); + // DB에는 공개 URL 저장(프론트가 그대로 로 사용) + UserCharacterImg entity = new UserCharacterImg(); + entity.setUser(user); + entity.setImgUrl(publicUrl); + userCharacterImgRepository.save(entity); - return ResponseEntity.ok(userCharacterImg.getImgUrl()); + return publicUrl; } catch (Exception e) { throw new CustomException(ErrorCode.E_500_File_SAVED_FAILED); } } + + private String getExtension(String originalFilename, String contentType) { + // 1) 파일명에서 확장자 우선 + if (originalFilename != null && originalFilename.contains(".")) { + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } + // 2) 없으면 MIME으로 대체 + String subtype = contentType.substring(contentType.indexOf('/') + 1); // e.g. image/png → png + return "." + subtype; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eb8188ff..7e14b2fe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,4 +69,7 @@ auth: app: admin: username: ${ADMIN_NAME} - password: ${ADMIN_PASSWORD} \ No newline at end of file + password: ${ADMIN_PASSWORD} + +image-dir: ./uploads/ +image-url-prefix: /images \ No newline at end of file diff --git a/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg b/uploads/character/2/93e02b99-a2c5-4119-9ffd-c9e522bf2348.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..98795f0cfa6b6a17cda46263e0c280069ddc94d7 GIT binary patch literal 382330 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¥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

6zqsCs==$eSD>3Y;?3oVt_?MFZFqjY4R}I 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%0D81U5lYtAI`t6hx%W(8GhLAnup-cxGDF(jv8c=^u zZAlr6N?Ycpb_RVjGg+h%-U2VMx5umOxZ@AAV~vt6POwTPpq>aG$VxpVV$H{0pR8nM z4CfXzqb_{F?9}WO?d0+_CGB>d$8m|&6T}v$oD~y}cset!+SdoLy^(GxCG<@xPH+*w z1;6x>7AP&amNWrq)@Lig!^%1w4W+lgpONvj&lC%#Z_75ye$*kDUOGt6Y+@7EN(GUQ zb8?rIbD+C?Sd%{Gwp}-&cw2G9UDB9jcCsK^v2Q6eS=#3^BDsM@5dN(>+?EC^wVc!j z|BfQ#?5d&FE`NaZal{Nc>!iO)LLwP;7XpMTo-N_DebRfA-Y+c>{gz|%kc-;eM1hS$ zYCw849kF66*^`>8!cVdkzbGiI3sW=gna}IAgIf(j@w;p)n|UI2>jzbrYPFBJC(ao|+D6s8gj4OQuBTJ~z9kn&Kq+=-p_h|h00 zm?9PD!DcE0qz{_dkE8~5HXK8O&rS)Es=g6nv7Lvt3ar(U;E7!KlC{RY6e|dkzCGkr z-wyQvJwxb&;?q$Ble7@EhJ2KPti#2-dxuLQX=~$J7*dNX`@*$RlI;qUsYya!&54Zc zHB88S?A9QtT545dwqw0I_1lrwrh6StEnV7u0Bb@W`t3`S9f<9~$mZcBz3mMnW1WYB zBcr8!=mKjfKT8gQk8J@vB2CfP9ck*_zDpz*rD& zjbSIS%Ld-@)bn7?dIyMAN8$d-0a9~HT5W%M)z97=mR;Le67O>SHdrqvj#@)QLTNxp zsp|YY4xYz`BdqocWR&e%^Cj-FW^yDRv+2iug&shQ_wmyeP^&GwveYOS;5b&2JddgKFaFgR4#FRHfOFZp13 z+|LG%&RcB-H)|46D;{O{Y3DV}BzQ4ta>uyP(i=jWaoCgQc7E$1WTuj{_oFsTuo=~mwA*Mk#iOo}{;DYx=y#M+_IGc*~%=SV%CAyn6| z3Q$ER{LY)JbB_=;zWxW-M#Ce6FZS4a_0BC{cK= zdADSgNvafP(oK;tAr~^=8v3o!u+|A*19Ae}A)c+xcUDNR&9$87$g2+k;;408U_{;W z%9>T$$mMc6iO=){mk19oyG|Z6VeQJj_9#MD+gE5e8pnYQ=!m8@RXzRj65~eVBeukV9>Ca9g9I7sDzz#@SIV4-k;_M=l zn)Sh8q6gaGC<`o z`p4pCDz6-zselLvec6=JW$OLNJ1?PjW0;R;FW<4khpUgX7HBgMQQM}k(9?Lnqp+r# zc4nH%G0eptDa82AYy@;=Xjt$yo}Si+x06S=0O!ZWwA~ zbTOYc?ua`A_3>WDm-vTX(Te9ZV3cBS|E;U$9JWb7WCra+bSh1&fX($UVc*j1^YP zJM0;4IKQI{M?w+stJ2px&-VPn)?oGmij0qT*; z9Njd3@r$U4Hj053%1jC=$@h)o0%p@RJUN6*_G~AJonpBdWF?XZN&%*K>KK^h)+LmjAS!TU%?^Zj&;_z{GfX79LHlRKJj7t}meHMI6+gKkBUv6C)E zfbPRGGWF+P(GGSVZ_XanZUF&}61LLhN}JnCTd@q1hOlzdjwg=O74|smMV?3JN9{+_ z3~ZL{N)bvgO*4UDUxuP^}8OS`5_(o*@e;P5O}(C(AD+UDm1wPYm%RImCf-7o|ucA|D* zC$*1L57g3F)oJ8&pF2SqBN_qEkBz5fx#4VUGlwZ^L1}g~_fc(?`j_OY3IPF&4TaJzH@K`bOXSNpu&PA@yUM0_ zFa;5vPsAmx(1J=>3Ari=NGjZ)s$m7cGfH$g6p;{taIK^zf-5IFWo%MmIg3a|5f_-(t`M^I0Si72f{9LT7kqSNOEfTGvu*%tovxVNG;gl!?3L1!S@69Az zj8O}`w3g`JEP)Ov*R6pFbn0d&#$Jhofk)Y86{9gu2G7QCH~=iW=vk7c4PSL5G3hYF zOuqg<6}T8v7{_eT)KyPD1=goQ?^&eOqB(@0Z{pEW*JE zW!F+NFqZU^R|5H_{XuBgE4$tqCH-06s5HQ9GkA4YYKGaB^F%gEtu+%0?Ok?C>7SrH@Q(jr97^T_zhA zRgQ>tIo(+kU{#tgA{H@|SeU2WmwkE?B}l=diDWP5ZoSH8f3PrfW=hN#V>ZO$hU%TvYAa}2qFI~Lz(E)kAd$!3z*B?@X;tPu@{6#Hizvi&Oa}}%4%?DwZ5NaZE87$7nj&S zdT$Cb&GY)kQDgvv9Gn#zUY5o;Xrd|mHVQhq*mx*4&TY}7LM!#u;Pb^^SZ(}s13Qu_ z0uH!Y-1^1z0w(mn^s{Nd%nZtjcWIc73PX#ZPo$5Y$KwxFx0v;f32C^p|nG2kwvn?A}%MYWL!l<^Sbfn4|Hg!mGY)b47 zWpyTI9Oe&5*jd;k*qmGxY9%u800PDyRi9{?$kfq5q->|uqo+~pjL9aR(==#GM2BHF z-?~+8p@~rEtDTXW=!W>R=j3%pk9f*ByF9luuE(L;UhaHeO?*^Tg&YDzdQmkIlJisI zX~{w%Ptz-2S$B*m$S*wj_=%j0kDGV>Q$n^LcuFE`#6C+|woS_G4uSAH>0EW|*=Z$~ zdIpE4o`MX&el`32YdeG4H0-%>RIrOhW~ZNrynM;hS3EDkUp|DLz`eI!SriNk&cOBeIhQ zeI1)p6DP+k^;$pFgv-OkCr#qhG>3Zu*`27(hNc!(9hi0IxFEnrFSzbhR>Z|}l8;7q zWhLo1_|Bs!9Bxy*G%9YZ@QTC%oInifXED z+qEAbD*`G=Z_;~*&{63SdM_a$ga9D~qy#BqrAQ|bLPseH1PBTwq2r@M=tvC^no=b+ z=?dz*c-ObyvA%Dtf8(D!bIdWWF*omf&g;I;D~#p**e@=!tIV)J+CJTW2=YT4uMu?x$_I;@`K~F zH^oTfHZRV5T3M$rjej^TW!Pk`%7ALqHz^01JI;yEQOh!bArwn>MQB7~tC#<%wib<0~YGy$% zbB=`Zn?ZR5(-`|@=t;5FpoiM-JvYlfeolQ0^?4}QW;kUce@2{rvtQ!q2N?%%Lf6)cmyiN8B zj7KuL4gRw*S4CXcP;I&eWze9~O|EP)}9(4k0%ig4M9H5V^Gy923M8N?Css(-6J9d~F_uvqBr`sZ?{ zYSsQ$lvK8vK(1)t54`3&xO>{X{iD`&kAz=oqPruxS-vN}UQBw~QCmk%TzFZ2#!W&# z*%}f!&Lvx-tNO|m#kP7U$8e$0C!~qGd@_=I#gBrN;pJ!H2L+V^QPMguH2`0@!IS(J z@TXoSpMM7`Y8ADyDt>8y7j?ybG+!}{*HG+NE)VC^Tj6BY=^R-T!E!~KxLm9#!nrpn z*xFFGA%VA!WELF;vv<*UJT~{`xAV`ovVj0@C35*pQKH!7mRX>+r5qU=$~qdP%D#8T zk||xVKf<50K|sSjSWRjR&T!r-_eP@<%)vD>5S*vh9`ItQJ@1pBFDLwk><3Fb4WFBm zf{(mf;J->i$LfueJ`COra*1qTDCKud_^ONZu`ChlHII^;7mmV`ui9-HOc$Mauf^?S zny8yoBKo3@fz>N9jE$)rz+XvIjLieWTb3bIvy@bJb1`}m{|H(696&UmQgL1Qx^qSS zki&y@##H+!&uj_7a71Fj^V6xI4+J=}DzL=jFo@MYY2RzRqaf33-T!tgaH(QnSlN+` zd#*{y%*4zvSF;M3S&Qe#*s5dOTA%(|VD21_7$N_@R6lmM`(-93C=ID!MVsG!C`XOMHGZ37`?UgHR7 zP;OT^&S;peTLd4~9Ygk=`U)1Pad-S3GC;~i$B%A(FpR5UbYK|8;Q?8Gr|yy=zd^tp z3}w^K^(@gvx~@@Eg$BXw-FT!WY?p)hhCwZ8%{R_IB68-5` z`0ggd!{#il{h!A0el^MXe|FqMu=pUWXWvo^qs_lr`+}vs1}vF>JPXimeC@k7@9$Js zFa8FAti|hbbt6q$EmkOJ%o$qX4HUB?kZOT@r->-uP;T`P8YU9o+Y`>0ofB8K7{N=nJaC7#IbjOxK znWSd#PrIpOnG+i0?ph->1o?(uRLWQL!8Bk<5R3c8otrt+NgD*jZA+Ykl2!_1c`c0n zmAy!0xQ=id@pQSb%^C10BLeL2)?``#D@>3!{pa{29V(LVC*6cWrthZL^#<8wkgjkA zi_L&P`<~0+r$ooNT_ZG0176AEu1^n;1m-h-%Ghd|O&Q$qfAF#jBSfrfv{&XF9@lh@ z>*1KmF8KDbduSM^I?MwXSe`#W`vf}r#G?9cs}z`k=CcEv>Qx|tAm0VRd(l)HUPcBW+nP+Q~!MB$`-p1tCbx}c8=h#1{a+Q zQ?ufRdGg28@AbXy9UJXX{b#JWtZ}Tc7VB@0RKCPLrlQ(GQagKVItST!WtSh7fU~kq z$VYy_$$iMe)`ewqqP$oI~piYwq)k(%e*5T?Sg}dFK$p**sOjnHtm6qW`d7~ni@{!dS58F z_Qy;0zN^H@7W0=i6~j(3+JhCF9v)IVYpye{#TC^$4T}WnkJ}1sD+pqttWrQZ3J7S6 z=fGRXuJQ(69$l2>gvC3*j3wUVP_f8fbOQs&+Ajv-PMy$or_K+A-T zZ*3bJu11?Qy+!+q(0p$WjW$iUR5YWjdEPUd$~ni59VKQ?lkW-n$_1zaB&O>&q|LJl z_iG+{QOTEPuQr10!`5^_@g%Ry**@G_H8#d&Z)L~ZAFm3BcFyhy;S=UESYKuC>{LFF zt)hX3ZB~dw-J-ydCJ=NU{fzl~ba`MRK5uyH+#lrSUZ|WjnEXit?0a1-;e1vo`JQqv zv^IykY%ECAiz!~}2}+GE>1SVWbw!A;RT6f8RgRfj*%<3prr^j9Z122;}H1t7Wi`eMQw8*Bowap(XuR2L_VvD4e0G3(p9jMwfU+NeB0n6BvvTUV@x1F zG{W(7e`)TJvd0s+nBgA=&06oU1i_mbDfWG9h#v)ttG6s#ZYw9*rli1JB|=czO(q&R zhwGUYy2IV483+k&%Nrm>`1i?%J&eYTV!Drx^pK*=b&{|TWj#QuTt?RITc@zQe}gL= z62_e>)o`&*NNQQ_Ej(J+a!tls*u5;ZZ{C(V#%yKe=TgY<%|kHfhJJeQSH@&W!un%^ zn*L`AxLa>06^W9FjE}pAPKuo{UN%qhm}d6 zN0yuNOJYJjlP73{TV{RyAcZ)W9AEo~Lhz_$2A63c9b67|(OX^2XEs*mLGT3<>(lrn znR79Xf6RG0dHTI9Z~FJR`M^;%of0F7D+U!ds*WXD;}X)!^!4!Am6|e~lvE3CA_e9! zwFm>0Iy=qVM`$CcAGB48Mu3m5oIFtXn(Si0MWtRA4wrb5G$qxCQ{Th^aM`9l$ntv+ zby;O|{>%b5W2+oc^{t+qgS*KpDGodL3AK4e zV)d8&n0&hZU6)YaQ>``R0e4TQIr9Bqhb^IF<3b&ObA5fJVEmSF1N+&KW;Y?PJMqNh zvxba-uO^XE{A0OIH@8%pXdgy{=HmtFFU;&(lyl{2iO#*)fA_>_NSd1CU?yi69V9rc zs^J8q*M&OS2ix<+`B#hZ`)@8e=Y$=DpXO=L6?ZNNE#KHOydj>KuGrT6i9pQWG6x@z z>K)Hg7NG4w0+0TMRZgplO7-Ems=`7n`pWN{ zEQ%VS0yLV~u#AHiy-Nd_(lB^b*tClFa_=Sc>$NG}55E5xgqatH0$9Es!gBJH^bNKg z^fs(vL5BJ56YDIZc?TK3@~*la zqE3H3m)FhLu`J-phgJ@`E<3;SuXkQW{hKt3h3v*{t~fpBZi1Q$bKi(6U@ku@=jvPn`G!qSN-xh z@=E~Gl6mB!?Yx8K0g;yIy$M=LlA@xzh@X#7K(HXOmzSrYu+JjCrGwRbKuA~=)^AaiA}5)()zA;RKbmc<+PYIc zpfQomX}8@^Yry~F86%PxsCJvdgtl{|3$eO-#sFqL_I1whI{GbR#IcCse9>V=VRh1E z3?ZI+2AS~$ky+h)!chi6%72dL=?i5`o0iAlgOxG6=kq)Zz5n@_HE%^xDcQsq z4xzuz*V~baR=G#pqY)cnlMK;Mgvd*>AuiO9udr{;c}KYLQZnvldZCvp@fLb?Sxr4P z&k+yRnemCifWHG)_R?`KYQEop?ciy1k&87gwI1<&U#hx|MIkRav&!v!w$=C$W>)bG z>LU7lZ-RDNT=ZHe${Q%+hSdt2A(`8jp|=JFlo1f^s2)Q@&9$qvUK1vg&zb9S(vwFIne3vN18KZ`MQomm4 zu$|;65KwV`lJ|}`&?Azj`d0QU!Pua@+OPQq-v?!*+)~*!MSwX^@tvCxhZ&UzvdgY+ zVrZKHKpekwZ6`a?Sef?V09CWzr+*q->#i6^1UJ9>`Pz#2d6m3 z%eXYOSdFehBSR*4?Nh!^EqKbtaf@nHMVTP4&n+=hOYWo$Nb_E~ z^apqGnguA!Rb#_GI|}PPzQfQID==bGmgvk4#VkM(Sd1qK$w+@n^laR9$!E0FXarp= zV(t%QiAbsEvz!%?>oFa^|J5fyQLv4qF6hup-_UuX;mPBa#+y8)Ml@G!H%rvZQmUthvDu&&g&9m$Z>vPF&4G7E9k$Kfx4e#`$DO>k?`z49RB}ky%3ym*ztd~l z1j*JPhNI3lIU~_6+-%B4(huwKPs=@SoCeO9u8I>zJu*2;pjMh5xex!&PH%g9ZQQ{- z5{2UipVckKo;cBWcX!`D?F0nV|GC2`%u;%7ip!H{U3!9doqe~^Zn{8;^@cKf-h&}x zUg!&zM9^@i&QF|=b_UW0%xV*iiEnYQzIKOPRfL@}EAtrB$CH$5_$VVDPIM}%k~50E z5gBthzeotUC?otrpBI{;Lj4x**ohRmpHyd{Y9QcmCi^51L2Q^UEb;LXk^4Hz7#o-E zEt-N6D3*)ogo)+CBJ2ukwnwIYKj~!jA_q!eb>#i)ue*?)<`O8A-vvRc;ZIEw%_d#B zUPU&GL-AV8=(}E=I?}*?H*-JHN`N=$da;D`h7%1`v;TA|=I8jIaDG{Y`s{P8ZgK7B zkp=-4-(u?sO`}>3W1cnrtmV4g8K8(`u20fA{eXek$sMrk+c}wSqIx1x6aIvH*Lx2u;F2@!0>W^IwL8_<|b=sk`3bh|qRogdQgRGqsVITx?rIMlhh zqq=&f*&_r8jy>UyJ@G_N65wus99<2}^A~JZyof#1QyKxHc~7+TAg-gw_{P+< z46)%=)@$b1QwD7;ZittnbfVIRH;01U!V1nH+Q2-AM&BrSuaOE8TOC$j=3QX6N#My1HT6txkE!4(^?BRK`>An`Mlc@Fng}G zZ?a5}Zl6`EdVF#hI<-)^uvc>3Jk_yX8HoL%d2m@vZAEln{Lisd{)5U5R^3B7{#4Z)lZhtuYT`Z1 z-Gb!?E{~BGfD$&vDNE`yuZMGe=i5}ZycpA#HXA+d?<8m)!z~3P_>!-mn|z>A z>lx(_oudwJbo!efp*X+z`<~OD+tc@pe|8tGV}8&7IsU!yo%u>1&7dg-vh`7CsxHr= zQ2ndXPP{s;T&C!=3jC>j{K#@|LV(jv=lH7C0Nb%{hJtmghL?71W;m&Ytn)NX&~h~U zl<{>M%X6-SwQG;;Aa*(8N&{ba!!1fY(|!H3D{ar6`xAhY^i z@`P$jD;y>_|LE$aPb~`9pr%f5W|lJ|ZP%DZaOUPRk}tW&x@^@XJ$Cl14r)6IiHhRp zF|@11NvRmtJ1dC)9|wk1Y8<@*L52DCTSh?r|V(#zmf zF!Q^>%^*u7KQm}?Wy8jV@Jb=K$TVEc~6W3D}&bQ@uRF%c=96_%2n_uTvQQ5f) zy`d3R?GMJ^8M0X2qFf@kwBvyZ<2e@7W&5oD;743(DupeRl39~=65;v7V~E$03(Dc0 zV1lla2t-Pkojt}E_N=dH&aAnbKiErT?@>?w#h+8z$V!f46ybwjyiuf9S&$Djo7YgVLsxbJh8WeX;&$%1lyYmo_*s3#n&#oMU zLXO_$URno83wvW|w}yIOR;)zST8rbT}(r+*e<1#^hNlMhK->B1Tw8Q5jHQLFXzN8geDPy)badSk@`svx#3K#Sz zd%XDWFYzqsG5EBu>LhW!Y+jfKofz}`wkg^X($ZwyxQ^uzhXa=m%b8YGo%tQpPmGcu zE~kH-Y2o21`VxItx}ufKl0qOqc<;wc*|c=H)!E7rD(db*#VO;y#UK4)o`BHP0lzwMzqOyRpHSs~vOU z_}IU?077JI{BE`|MYE2pMs-6m(M{dhuGhJJK(R3ekpTXu@z!buEG@b#9WIHaR3@0t zX5aQAX-3yRtUF5qKiO(+o3hFwirjcm98f5$wtPhj(z0diJ?Mi5-VsfQnMvvIyO!Hf z%`QEMF(;wLbw^{D0rn?hyKmw z6HhikrAF%t6C(@x3|CW!SdF_J9lZp0#H{6*flOsaa&qC;ccl8ZC)5-_x=}yGI3Gv( z<&!zL8e_8_TVnR^>i0B&SY~CPTX^ghCPM?Qph7lSnUv_R3?D*zFAJDzJw*Bbqe4Cj zoKHJtq2Cvq;^}#Njio7w)G=!vy2A0W=Jb(v`M}oj)I%9Cb%*o#nz^#Q3cWLV)Jf->j7Dua=>7hwzaV#K2|^#pq9a*&YWuXK zeDu?h$1Zv&jgv)j1u|Rdv^Gn0i#h<9{Lyz^O{e*jKngx^JJCIWRM1;9Ta$eC#phNQ zb+K+etd^wk@qt~FO&$A`E%M(Vey3Uw{v&lR_pGaNV!roebf?6(R&tJG`Nxe&(Cpt; z+wCg^8K~mwwoOx|T?%1NpO@p-cZzuub>s!#iX?A!Y%+f~YuU{7PZHzR?OXY4@Hc}E z7EvfjZIx)2PKX?!!~5e5UkSZns(EwPs~9HeYudgtG<-eSc&y{^ZhwReRZsrfO~*N^ zw<}X%*H5Wsk*=u z6jOoM*}6L7*=9Gh6srqA35DZq!QtlBJ8|JVYLi>w=o4M80%xiiOKfni(ZOA6_6cwYh5($HDN z#b)Hyu-{q=>E_wI)SI`?s-sihxygk69HAdhBcCoJk3qmPrJ2DOfs!fLZ9$9;V=Xe3 zjst~0jP|8HZtO2`P9YP7D);>fau9L98j6`HV}Lw2Ov6uNBRLB7Ey=ly(8%O-32Pc2t8s9c| znOat(eJ55-hrZ%uB%b}BCIYj3bXA%P)FLYHu$wr=T5cN}=;Ltjj%D!}OVsg)H5(ZH z=MF!n^%fZR6!Mz?HCUvw*=hf_Sg3CrAP>QQx0L*6BO(&fH;U=tPI^#Fy300HYeSLv zIjr3;!?U#Qr%_cZ>EqC2*K<{iH0Vkbc>AtK(krb&t4RUjhK(x>Xd-Aw7Jm3TBY#J< z=jRpq9@RJt?kSU1B~6t8lLXFV6J-Ufj{2E;n2DGh%1oX5={zP)SZqCuR3Dqg|16{r z_|~Z>Bbp*-j=RO3Un(9u4UCYBKoaFzN!ut6ol*7x)W-r)p@8UM5UcnxK4mZRd&=DE zWlga6#hW%uEAI+ZU3hJ_QL{ZS4qIM{=YfDx#+o?*u$n)ZJNR*rwZACpFxzlw}79$*@Gpf

M+B5ryL0;tzfrNx0p#WPl={F`Ex%K1$C!&bBk~s@nz`URiif5!=F{U;FX`@ zh4wAxm%cK+&UzzZ?dG-=Ir;+58sE`o#BT$M=+1DAdfKXQ9~#jnwx2Je?8lo~3mh!& z)AIZK-MSrn@{Umk5?hFt_&O_+@15#0T)ni>p*w=pbRYwZUaAKZI6%6|Tve@9Vx@~{ zE4nFmvRPgFe`0lWB9(;K)PrqwuGF3Bt1TKvL@ZVQVo$IWE1>^z&pNqM!%->aqr#-B zgKeO0#0|FXe9<+twgwdmKZFP3r30%?X3Q!S^A=_$D1{e}U7`LCbUl(iIN2Swnm^#v zYMYYZpfoA(Hesb_fpu8cSwW`fV#018=Erxh)+g0dR{Cv?7grP%lCl@R40%h5fOxMV z^Q&SlO_8zXeQ55kKKHbO`&gW<^4;4SJqNEg?UgU>)c4pI+=MyId!@lVxaLfe_fP-% z0%i~bObP3+1oywkFU>08?HR0Vn1gy8C}>H^9=3emd+Qe8jvW!ov)`g!XvYQ+Mg?Kw zS5B|7Lw4eTIk5&GbijD?`J@Jde_R}|Z3!#44*S@UO=d6YUyTy>+pHzXv=VQ$0h(tTIAvRff~L@g zM(J6#u|{&+t7{g8=_0p(^obYuNP7$ECmG0&N{dW-PF*#rdgR8}K*o14)1?fqx+jG_ zwcC5f$x+{;7M~sxM6k5@G8sRo@}i5v`LQ)o=wa}4ACW5D!td(F*1sjTI>>w&;%LC( zK37udP~#t2Y4@Q~Ksx$TXjC0`UEaJJu`y=WP$5*3&Tb0E)zo(|8E{iL7W6JJdR5MP zlT0KUHiMLm$n(HweT=OYu^H1Hcx?{dT&sorTi*u^hf*J2!l_P_d_E@_2IVx}x2=3P zG(jM~%c|)lew_{4Iq~CRr14iWmq${J(1$DQ(n{5LWYliwM2J03zp zF^!>Uc|r|?%UlP3<^$l6q;J1#^(D#MaPP4Jo;Jh6O2Exy%b~z6@_>JxXI^6%`4r^l zM4y|pGSu&jrGOEn&L+S-c!Gd&8rxMKXp3=i;8o3y;aRvM5KVrx0{45fU1{p=Fo}}$^QdKx$rY6;PSZtxbKYn zKO46*-gcke7f>D?H4N2EFG_eqZnZX0wEEilkMM^)**tn&KT#Ul27b&Ze39 znmMD!+9=3YrDGAOmYdn7vV zllt=p0dOz;c^_{`6Sk-MavCuFdakh1Q^=TfpUR<{reuKPQdXJfb-stgtt%Xh-V&ID zUD1{CJN8%30(^^0ZKJ7Zr3CBsM+Wu%q>xW2fw1mxJ(@hhHC^ z&<*QlvQ&d4t?Ged8`LK2~b+&976n`{H2S ze68D$aoz%ZQ}MOH-MeNsBbih6~VFZnsD!adXiRjeW&6Dv)ASOeV< z=_yy1fx++#(gV<{4d^5`E*F6&Ow^vf;m^yr7{qGRYSNIgY#+MrD@|D5Zgi)Z-Fs;^ zQ@kvBBZaKy%}~TGa(dvg&QW9fyjs`*&OabJ9F)Md9VJh zj2;kJ9?cl=hN(WM>!Hv*#|xy%`j*jFzUPjcWAfp4Z=bcq{q9#e9JjPkRR6J1h2zpo z;T~{f2lTnBv?p92WkA(elb5LK?QdQafW35DtgP~)bSWoR)yTau-W&DbdlS%EpH z&}wDVBf@!WnUFF=h5DqYaz(^JfFrl&?FiKZNyqRyZ-bQ_jDLh*pIpC%EJZ#02BM?n zQg6M`jb6y-Q2U8eSl~k+AqTn1sNT1c{n6|_cscxLflz*fF;Kb2{B!3Ll5O^XweBzhYA}G2q4^a{dGK#Qx z5q*>r$VEGOJDAUhMgS)4e)x!MK`~tJl@e-eQ8cyL&$8Wwyft3N@R07km&MnKV>#{o zf_mNn&PoqBQ+Bcv`|QlKn?phON=?Jx1|8oYi6~fYU}9&485M-c8b?#{j}!D*v{cIb zVnFDeNy~JEOfj_=i|62#mEz5ob1W1@0_VdteZ}SEz6RSnlNxxSTN(o%8pTX%Pqr*!@{7al_G07_nPbkkGGY&Y&ps|BV3~lP zykQggu%SKF8=f6{_BcRz4t;9VZ#TOXD)FRI8E2mmUrTcs*o@db>9|mS@upo=^ZFY-1$g&TdZ7*CB}&k0yUk~Sk{t5z5&G= zUz?8(sdh;;1Y&ZkO(ZaF=Bo_Zq=GMJ(r#`!WTK+54M`P=(lpD$6jwD}m7!OsxI4(( zVE@^ z;$EC{QE)W(ubSTMub9&58L~%UXm6;{Z_Ibk5#h7DWb_+my%6z5-CS+$@Nd3+Gm{mM z7fwWV6+G%<1}Z)ZMGRs|CJtTZ%IMW}M^#M!To!jMWo~eW-`B??C83YQo-0VTu3>Y4 z8TQhlAP+HFH$CsCrVtz$)}^_FOihms1U#F2ttE4_j~kM1dqu>e-u{Uqr8Z7Y)zIBx z-l2vnlrG}yvR{lkvWq1GZsWk|F*5I#j|uBq3f+=3U*-1><#Lt1j|WTDoh6@!iFKgZ zGzIj+Y$j;C_Q9I2LBNvo4OHo|;l3%Sf@QOx?HL#C0T<6Z#FwSGbtba$UEI6 zTPWBM0RTo&5-6shT*X+Tr!Gr0^Q^UZJ8{&aVcLQha5!VS-Wz!ZKbdZxFF9T1Yx(Us z%^l`|k`^3!=O?-%Em3@VVtocWC(Q=lYg52K0>P5sb`GSe$NSV_+pJIh|A`v72Sqrq z%vI%H^Lkv0*-0}F*$U%SzgOLJ%dhV_vlX1fbl)J~@a_)l7Xk{Iz{f5FUY2iM@% z2)KDbXq!|l3=u@Qc%U?b>p(J>Hw*Yuv-b zQGE!%U8vkQW_Ideq!S`tEP{SjRvXKo5iIH98Z*{4Z+@j#taygef%Kw-no>;>OSGW*)uNt|EJ=A-v5Uq z19@weaPo#)Jte>m3^NIn0*_KXX!MFf~lTGA>fZf7*# z<%4{vI#D8?z6d$_f8X%ee=|c7;{LBP>KWnLzl*OrX!`@UP8tUc>~%gd!gYHjtvqMH z4@-a?a?d5{R=CtGb!BmdM`N?Sa`r7P#4L?cUJ_6-jcEI~$KP{8Qra-pWNO zW9ld6KmmS~*N(V2;G>jwYQw-Xe8?DCZCg;Ed_U;}!7Pc5!!7QlByZrA)_|onc@fbx z0Ai3bM-1)u+5N!}6>@9G^DHZJ&>jWtDS zLNoJ~E@R9tae5hnpqMfA$zT3{ll%A3h#R*J&TuLnYDuu$p?@o&ByzAbN-p6U8!9~ktn%KG|qg?I{T1px=jJNPYD3@q=#1p(E-0MGJ4$~c! zzMV*TQ**;g@#BZ~bwRl5du7auP=26=T3i=rdC$=0lFvNZU#^;ZsYEFs8J2s;tG1J% zKM!&;#@NN(^T}N(_0ay#0jlQFOJP5ggvF6SSjZ%NZS|(~VOaT(q0jQHoaZr6cuYo_ zTCP|KAZorg1c@b2n4m3Maij=ii&rC?S-4-cV)u5(W zz1&P{!YK!NIrYtvLtaj(l#?#H3$i|OKoF+viw}o1X^e4-47Psk6|x53F;A24@J8Tr z%}*v~F1(Yt^X>AP06k3BYV|hQRWw0YFwDz06mFTgw?9)3-QKlKP0A*Y{&D&~F-UNK zydjeMf+Dsi>E6<*TyWYXEu1;KTzkz5eH`jbac<gHqKB?J}ZFA27iumLFQCRhzs#XNK-UiFajUuD&!?rt^V!chef0*qpgsjcPL%C~Fv8 zg@+j>@ZI0O)VsMtJQL&M561o&h9LctS9?cg>7LxuMqn5I0uP>*y+?CF6Jf8AFSQ~u=-{#pNMwc^>YDKdJC+{>#KCVrzU zN<`6Qe{fc#r-Rz8VdPq3_Xg79(=}ZW+YZUB-KvQ9CK*b*c$1h@f1kQI2HrgzR37>~ zeVW&S@%O5Kbv7p=b$a+41tKUPc+3mkXGpE5xF6y>0W&f)+$c#9jG>SRRTUZP zTiV z4F|=l)kt+qNe+6sPUy~O>Mj3Xu>bdU0e7CMC1MxczjZgfH3v=t*IO9=NG9GbykQqz z88+|1&tiAElHA7PR2*?6j{Z4=YyhU3x<-{Z_>uAyk( zRabIUsM+xw(OyeAFZ1;Ql#8uxL=Pil!@26`%Gq|hvO_B^C!j{40(&d(C+o+qq0-_P zTnZ*`|MazXW&8ZRQtEdz%he&}W@)ie7I{7iGdSn0an}^g-!Y;)pQ(Hm^ww@{UO}@} z6?kdDeU9-#%Z8h}c;`9bxKfz9Q2EZ<;fs^-U{sCpxOEGwBWJhLW3U;#!QDC>BoRg;2fU^?Dn6CsINP%Yw{Bx`_m)jv{W&8Gqbr^aF5EslGi1ZIg-F*sd`x%rK+tR0YfrT(dn&iRigBA&BscbIAqT2V>k#JL1X3$ zxWPwm$yI^4U(E;4vfNgE0c|;_zNLk2W4PfBy{Tn(@NmnYY4b+?HxUTA#l{W$e8V8b zWlz|Z>Khj)ih6PZTiXK}=C(3GheGGDZiGom(U@6iKw#IjC9cLM4~m(_12UvM3R87b zQl%%;=-E!E0!2#$v`fb6JYQ#iCqcw$HBMT)t~IQrQX!>W4;)sdf!w=Q zoif1%OJqY4SSqD82I@q!nU&k+kfh~Fz~o)?_y_Zt5w%!@fufafH1PVksm1EBu5`F& z?5o)?dAKVR%Pe_)PHnYwKs>N*RJ1*LR25pk__i-oJz?P`i{Icz>funrzIgOtC~Mh| zaZbu)-)gWpXXwj7+qb8#)DOs^5zDm-)YLy8Ay|gbg1|`?V_{6#a3Gb>g_l%Tu#Gac`Fg?C{U=Cmy z4m5C+a4dx^H*wVz(MR?pf`!F+=E&dtfYY`&a*n6G_=IW)`^nc5EebL-Dr7_lY~?__ z5%&b{QNhWl(^Ds*zk48$3pU_9lqBi#w`P1Wbk?7>v>Qh!g1>JPIm>0?Zx9L)gxa#` zF;{`?;*3;L$v)nM``9!}O7lRojc`^4rL;BY=<~7`hF}_cZq@f>b5mtE0?9XMC-q@$ z2#u5`i#%|ll04qaGJ^DJAD>n!3(Ya<1?ZPOa0UF$LnFmpPR^0iP1jH8zFB`6h%Q0N zeDaPTWc<`-uJCSFRrnckAkoo&cP$g7CfZc!Uk(GNScW6 zyvAn|`zyXR*^w%ys8!T~5Qcz{={LlXzZ7lX0DRCy0y@d=1Gn-epBk_lMlqlbyeCS# zZ+F^U)4eeSzM~F@1&xH~4SqaQZK+*R@0SvOTysG_pp5uJ4kPyJXq)R*j$C{Q3LE5v zTe31PwL3!{hqxs|6#lJNtUEdQ*Iy6+x;cFAd^hJ>^ysiFdH7FMcpr1#D#z2)=$Z04~MzaRvl8tpFE#lWQI=5hU$u;)q&!paoYiPXRLO{H6(VI^<-kV!q z_(z;XF9Z0}zp`cD!T zsZCSQXvkT(^}pBns|!;@vj)7 zgwrk!7d3IX$#q6}W#l4iW2mS$ljNk`#RFOh&xS|fMuno2qo`@-O+2fw?{-u@->13Pa zGuU}szjcRs;^vJv(KWws|C8jFXg!^x9B|< z2gebzI?(f@Y||CMEdU3)WHVWQAjtta-wDq)2WgUJ{C}T0aNYA;6C8+Q&vLw(Xf)|- zAg~`x2<3MBG>%e?jOsNPmuEHuNuoDfX(EAP|raAQ)w)+ct{b_{f(wxZz62!&~4 zuJzdl2;4OOXBbU}4OEL# zbAm4`+%mhwJ(I3Xw^fVn9%YFz>7!-$2Rk%iYTwWMD6{Y?pW309SyYtt(03~q-R3`% zEIKH(S&$S$wsoLR#qaxt+QCG8#Atbt$A>8OE2|do6F_@`+XXPi^tsNN*`l54gHI~6 z%BH_aquqEX%}^krmhOIR?p`)qMQ%b*7ab-IH~BvJ>jGubtDZsY$3aDw3{PfDS5egg zd&pz&zz9$7M_*=xp%D5UYe{L&=a*0n@(h-g^DvNH6toFz%lhl)8r}X0-Tvv>`+3iu z#r>GGbDio*M%JG{J#3IDwAli$$Y|BF-AT{MI<~K1X2#Vmi?3yLX0p#KuHydO!mF2) zaxPV4<6;(OiAB)(86@n8l2vSbcXaOg>;2ig{mlG!CQ709A_36PFhPci7M&G~SJCKx zNFJbqOy?>E`8XQI>3+U0z4_{{xi^R7NqK|dss2CPb=qVTy+Vt9@;TEl*Yt}t=KO>7iiT1nR$}cP&2z2Ou$!i{_tt4xaW(vyDxhHQ|I~M4)wAOyKnr)BC znnUrE)#sMhu}+vtgy-}Skgn0eFj~j8X`Y4}IJjFley>%fz6i|PLL&6b_MuiS^hdts z$VtWkTMR?*wz!&2)j`7UqL;~?GwYLF-!@(AMr*-2ruNwOt+P;Z%9_-8KPQu?Vy1M5 zvf;MPV6OhGnbhPYY*McN#s6aOt>dEHy1wzj06{S*X;eTYm6n!9bX$Ba2KpIp~ zT4Lyip$DWJ1f@f|LsGgse{=409|v!J-p_OYKJWd!=e+OX53W71_P(y!*Is+C_^!3| zzK}dm-Bu`Odn!TN!SqSET;_Xq^f|Q(9b6p(10&%_fd~f^@Uyt#Ov+xBO<6;oLibnVXdPQ3M?n(ZtOj=-XuSvv z#2(qa3}%VCubI@oV+LQ@uDlM*WSD!XsbAhbf4N^vn`rUn1kt>AZv|}R4idHZ`VUkxoJ2OI+*M^| zrJB~V7C0gfBnD2MTCa|R%@rDVyawef%AyT8UfrY>iu8WegisssU6AQx$>^7+3Ssch z=`+o?#LLdnmtI~F9UKX^QNA0V5fJ_aJ*+6NaQ~*nP|^0g8NveM`sNB5I+X$WHiNC` zCSo}y+teC^E$27k(qBO$P0vFoVh0GkN7tz_B^6cPj1a#v$3F+`!b)Sb9UqME%ITBm z856{4#_v!K?=nN)Nu(Uo744D7;k7Ek?%gmuDA?sN$ zdNAFwKU=REQ)b~7eOM_yJ8a-+O5%DFdUWL0oT6CD(_!0W+?>ilzq%l{Y=4HGn_EL8 zTc)D+9JUr>TO(h)re)+Tp+bmaM}k-<=RlEaE*}^^cu|^K@-CrZX@sx@TWIT)RqJnZ z%aUC{3VC{)@jw8Ju(!G(VW_cWfkUyy6a1)Q4buE{;^Zf7H!L3r5LxOQ2b9%vsI*zg zJPu~Bs7ZdV4jjfdP!#rm%9j34)~03%+eP?=h6KBB`Xx^ON9tm*-f(>)-4ENh%8B=? zbOWbyuM_HSV1Rw`l_VPOhr}wAhK0Y`hlLEzA)3hrL<4XZG^C?3KEw5@k+qv&=-aW= ze~#Cl{ZP8tDemyWFGu=V z*!-oP(MFXNE%S0rS1Gqa*)lPh!NNWOPSzal*Zlr9saoZe1y4pFlIN|_I#Ka0V2R-J z&ToC#h-)#k9ihiJ*ewf3Rgl@R!8t0l+IL^PYu9gOHpg2$vOBy~K#(#@G&(2Ww5-@T zqCP|#D9#ErrM8q}crQK@`d(dx6mIC5o15|>Fm>0nG(zQ|SJF1mil{{!SuZd}@%p!0 z2VdvYA`PG-Mw)`Gg!NEM8N+NJs+V9TAU!8f5iO`4n)ZHLPxu&JurGC4$jKBMRjP52 zJJ&SOJ0|-ql-^L$SJ~lu05f+ciaf^jE-WG-z@24dMZMq6heSdi@OGDpfOAEqhCKOr{}klvDhwUQ>z|Arz+GJd8>T{t)`!#P+igc2sKIn|n=5@Vb4 z$u)}tbkmMYcQs?UMRB0PAv0qgb2FVw8Mwr1GVL^X()J(|7srYuxmWXr)!f=Zch8bxvA4Ef zPQufCpzp+4r&DF(BB|5irDK|_TKcd`OAl;`BZP8*G&fI=9a;VZg3m_AyQU+c(PnBK zHb!|;9ZC~g5+}U9@HiGKQHkz{RBz_gg!B-|vcXKPqlV6%!#uvzY7xfhzUmM1#hymx z9U0XZmdi7ANQ`tiL?dn2ZW;_OPUZKhwWkl}54OsMLv|fVQ+MxbRgH{-?b7nARj({6 zr7)Q2$gPC;E7m`qkdK`h5*?$y4x0s2J;cW^nJ!a?*Y0I7cydc+L#lLr!$#{XJuAk$ znKj8cSQ-u{NhqrdarB4q_-}^QLn?awWpINypS;&q8HW@XRr(4VN|lsoXqY2tH6RF1 zz_*0;b3oAYna>d*h-%Z5iGwg@hw26tNs?W%8v6-$spRfzz!BJXV6O3ww z<7!NQAb5B@OcJJge043N*`Pr!I%pqGd38l*DCW-a_-&c!LOl+m`22bEF~!$W`1gl{ zFrIKf{EB!bcvoTcohl_ru#_@RrhD+!lqCsq0L7YKAQMYlf;rXLiYqdpT{Gm${Fr3c z&Nh$t5G10na%I)ux7`(l40}J~R2dc>=zR@Rbv3Ifs)RlR^W<|HHgb#%SQMv5dj@wt zSki0DFHALZz6u(Po8u5_4kE(d6VYwf(U{PP2gwSx0C_p-@4?u!Lme05aA@h$Z_X$4 zN#w>nuf3gRoozl$9YB!w>6W4Yr+Z!dP)tNkgl2pQ2K_v;MqL}N!Ww_@a;ZvjO+6xa zNs$iv&`N2g0;?l*w1sJqb|8~VCKg(B%BnUM`GqKKR5!w*a*n4~@+EZ-tYlOgzEe|5 z0u6^ST$Y4n+K(5gu%ivf6%=0T_*z(PB~CO}z~pFb9olZQrc})9Ep^SL;r(DJom1G> zv4v*YzSo^);$~m*d0IpOYf%}HpgL_;Rd=t!WbccbI#ClBXJHG}k4L|n)pITyHXMT? zrrB1o`$WQN^pU9{OT?uzs?M})(ku;{Br|s!RK;^_tYGH%9jRg*9}-K75yl_4-}`jE zWVxATYyIJIOGU}RbY4};V>%>CvA8UIy{Au$4H(_*mi=%;B@W8H}kY^7GR zf-A=|E7m98+lqZ$Qp{nmRQ(@2SUs)i*}sbnQ*D!09&ZrbLx$B?3|yX2C6Q`>{#EyF zoOSd%lp4F|TA<5d?EOuj;Y)jwZp;?$%tBWUXbj$q8rhb3F)%sA3c4DtfR|&LWW8iC zm0bgvpwpai0l#-U`{|GjKKU!z59fvlvM8-{%+TDZeQa4z5Q{gM9&UW~4U~}|x{2jA zky?bwLuwetqI!?EXg|3-BiV-yzFO{RFc-igyIcLi!IXfkd%(Dj+F*q&o@~>T<>|J@ zj^{amqDpY92(Ha%*pmOST=G%ueOn0wSmx)5S6yi`!*9A_{$_Z5Y~)>4i4#caL6<== zLxN=m9Gjm?&+v>CmZ)7o$rr@YLfCF)&h1MQ%P2G}^y;0}GyCHnCOIKk@Fx~8GDupK zRQYFnE{bt7LTV8NfO!wTC)Tk41fFPpswUn@?)vR6Q+90d=gxc#fWN_p`^z$zsC^ z6_g}yr`+quQX)6>3fF>Fx}EJhKKV>^j6Sm}HDw;*v6XeTQ*EFAv)28BRD=ZvJ2z-z zUKQPsc|4Eio3#dPnMNU^LTyvu9;0zI`ypV!vJ2sdJ z?%%ulW=69vwL`hE%G93x{o8VSoU4{YF7r`FM}vX&hw9b!a|5Ik3ZW!994g@?st-aR z*{!4)YBB_-jZ{9XWVK5#w7wBddUCtq_+ROD?M zeWRkbj8XCzh6eigj{CT@Cu}R`*09Rl5FB;p+ugk5_D{Y(g>rp2EGzo=quaXZB`A7{Vau>@#G=Ik2x-{n}gx&m*V>$ z8&)One!`vNN!`-adJ;dD_-5Tp21atf!9sRXMGOzuTt8=--bQIpKi}+&s4k|AnqIYw zQ{+otI$Ol5)rXD;@?3t{W%>Iz4tg$GxmQ_o2W|B*PLaI^SXEPtRtT)k(5enp z8BrN=ke%xnH!pkUfCsZkoba2nSWX)gkre5PrR(*odl}A_`E@P*`qvYtn`?WgJ9l#H z(&IwQYL$srw`HR{qT045QXba`1X*=LnlSY48a>S{U}u?0VU%lC<`yeR{XCa?pOLve z?}n@cXGun;)y$f@Q~l-AQ?vyN;D~ZxT55sC^+sADMa?f~->}T8j^c7qcYG}J{$(ShV_)DC+S5j*Q8yg0Y**3(#Kfk5bSg3Vo`%(^?i|2Bd>HJoBm}+s!l$#dT#W@qDIIOS65ZGg&!%3Pr1r zXHnq_EGuD?pxH>HF@`UKhDRo-rDXz{I?XJTOk!g0Flr0Imiwci*MR}FVj1$TU|mt9 z&@I^U?4Pp7<#RyZ3BP>WIY4PQg|xyK>RA%;RYe@SywRq}tIEjTbbGAW4(o%YjZY(O zD}8XVa6okuZe<%X9&4phv$IdZfLUTQivViT)&%s3{KQg z^>vxMnbQ_uX>7hQ$oNQeRIfd1pv~7W_kuxr28m2rbK!hjW>x5P{cGcTyPNs7DsZ~` z=ao8eB3Z*OzQFkF{=iHWJ{wa`yPh z$!Z^sX@M=0M$Q2vkjRn>;jd~mSSChe)+C;@hQmZxq^_jFW(U7zvbJGGZl>{etL$pRF^E{SYecID|BLH{)X*x{VPryOio%444tg(74$;2VL71GRzi~f7V6EXrsSa5EH~r z*lbqtGz<8}rq84^AH>a7NDuPO3Yi3{!TD~@NK_ORjUv&}f^c5dOrHLSK%KQvg^GF4 zQc0Dr6tcTb57aL|tsrwX`^1()k&bgi4IwiyZXK4?2y>-(?Pv9?fFYQM294a>7UJ8q zc;;_tkhO)xey*f!jx)9_z-Xijewg|;&F^--=XFwflETw~eR*O!?S*OQo_^VMXfabU ziyfxo5J7l^3%bp>Pt|Bb;kdG>Hd?ZdQk0xSrRENWuQUQLS=DdDiSJ5rz0&zSBN9x<8qszmekWJ>La)5T`h))(gfD?M|bn z$?p))>7u^Ic2GAj&v+xDxv3)MBYcmItIN3y8Oqhcnte1yJ?|MaNj{2FNy58L`tosT zIA(3^(H(Kb_KTp;w!Bz-ZHyn>sVp7Sg$d1v!8vfM$r>k>T63QIM!Dau&v4yLr%)D# zJIqs+NtyHsb4n$L#JVhEeq^*_qthf==$p-V6K%e6h=caf+*YiDndaV3t3u7~IL5(p zY6~N4;QT~V?B_L-Ky3A+wDEgpuT==o0WyY*F%*(4drVE1h5PLhK`;7cYg7$d8ma^E z=rq=}mwoMmQy}^!dYvKj#E;bmk~R%}pDU#cDXEclt`vPy9G5lU@A0;3rP*B*j?qgy zk|xVieiQS+*}t4yxW82yPkS0mgT6*U^1(7rxDby@#iL?krA!kAy}bOxGu=2KRO)3L z-ps1N*J^r0P1Q?{%p4|4%w*A`7Lv}k^be$P;L8y!#pDjMU(1e$ZAvljQubwPmd*4( zVo8gGnuXG{EtU6MQR54TFPfUIDvP*%-O&qr`;n#1L_L_Eaxp;J&)dU;eN3!YRX?$y+EzgLoLsWvv$HBi5UKwE*r=mg8}odX_*t2ac-m#Bfo zh8>Lv3`-`|!*zh8uHNcUda0EfWYyn+abV99QiX@59RUWL4C5zHS(asq2t7D=j^ z$dZSPb?uUemj{zJm2Z-iubxT)y>CBNLQg5z!n7A( zDKn3*%~VpkFrCkFmkq?#uef{$tdo=FmTI;1rnj<|#MXUw4)E5A>r+XKEVp2(mZHsv znFhj3hz*EEKDH{~cwV$n88Y*j<18$VOm1Sx{1(0zdDFe~y7D%w^mkZ3b7v=~`^CxDR+-1mVpg3+kWX}-_5Wm?rK zY^{(?_pCj{#RCbMe#xR)%qFXNn9-RjikD9>!k6DSJ7$xE-kPOqbJSXQvT4FpetF?-wuqb9~9IeMS%Vh+LUQn}|Vr-40l<|aPq2h{4 z+1;Ktv?!gMLboMO`}H_7-zeOh?GGNLt3=q4L_3DPdeTiJ;$oUAPLw6|4dZG49RHXQ zUh~}bu&6$Qo5Q#IacCON4}g!^TzWbwB%BI;-%|Ij=uL`#0zGjPzW2?Sr{=Xfn?Lm895 zZ)F0Rm)#=SVV5^n7*Pq&Qoe0dakhUgn^yL$i-&8^aT+`N<(*HLrdn=HXbvEAu{gDa z`0yUunx>~m>Cf=mb31;98=8ZWHO+txxVn}So)5Ukt7)_Pvs4h0=_>mGwC>n8y=A2) zb6loA7*~g>QOR*~h-joyvn~yzBG;Sgl6GLzK%uY>_b{I3K%-Il^;c#oz&Hy#+2ZmC zsf%Nh)~4%Ekj#0d&f~X|g^ZG0c5QNz{Mfz*hXG@yRsQP)B1zmnSUt*2AVC5(xIzID zQ8Q?^TKCSup^WquIwjLr8W4`6nh}@Z865p~pLe{qQT7LK(cU$apkc};E)L!#uid-X z3L+kGl!hbVrvX}X6hp(L;Bl6_3)^vU5|w@L9n-Qx1k-fh$ywULq{*OScg(C0&WFsl zBbjIkCI>Tj=Ia&h0s^ugorT6Pp$h$u-=;~c0t&WB#=3HzW|fV|TxV*jvMx_@6i7)7 zV}BmS(m15R?$e?YtXMzW8j|^_us2iI5wn$#`xSP{R_DFx+_FMRmeCxRw%meai;|_$ z=lxOVfUMo12(+Ryw|DWt7EX?EK|@Jf1$-;}!JCggmBr}HnieJ#6sXs9CrZG!yZSzu-E*9@<*n3+gJFb%&p?k-w-1FAV-@Y(xwlei`3ddlt#ElD3oX!?u~=AAbkj=SOV-RqF4buBjy_RO71{! zwPBl-KJd>_Dx``3kUqGNvA@Xb=VGYgYpnM*xcFA*bxS>ON*j@2u#BYpDpnu0sB__K zn1+T^&-w3f{-3{bM83`3zjBOy=JNvaIhgC9S&;ved$QW4(jTb)AlBwM_&Dpndlu^n zx(FA>>iQKm0Pq7U7`9WpLHq5{lQT()&f{l#!Y?c@^P+9}kYk6Uv$n_6&&PgoPrrvz z*SqdJFEILeH@nf4P*tvm9-05hymlauDC6fbu6)T7xI}!ok*CV0cmXbanO}^TILMYn zE`x@`%H?U(Yxy;O@a~nFwVy`-gxDqoXsYk|o{Z&dVA60M^fx4!8zsOCC%JCDr81AQu%}v>W6=Vsjb{nN-0dL9tqiLuv`V}H#3aR#gVBIDchmz|$^B-wqK=hIIpz1M7h||ZjF|i|OH!e2 zmAou+*#2Bfy*WkG>w)6xIGH8KxpIzPq=H^2{v?H^;P^2z`9^k%qJRoZ@11r0VoX&| z34K|$a)kv)dP^01?vF#=L%HpRp;Wxl+Q-4Ov}*+!fhkQ!bDhng5sOLp>WTfz@wHt} z_gV+%>e}ODWG$Nw^4+NmQ{f z54nYOPjmZN{P^B`WCnR+16s`Vm#MQ6hV(~l6v!<`Qo5G-uA8~mA^1Ttj^g7+ugRFZ zZXgM4J&x(c$BOEC=6hdSI+^PZAK%ljOa{3I(ndE-=UU#Lzx#+IrGU55JYMELP|&~I znQ~M8vXW1Wd9VhEj*WJ?5-(bT8Y)x%!o6Q$`K)&Aw|B!$H~Kg~tU5DmLnN0zeB?u; z&}Z>xWP-)qP4mt{w7PON{Y#@uq3!V@)VVePSmyE?6(j3^*l5I_)=9-cVu^O7eVyXinBhK5fN zXBZY&le!asN|C*LS1PldCrXG{b`%yxqX3IDBVO(wxmh`;!_T2rdx;rCe>nHf=?3A-}Ty3_M0+zL7jTf z4sbbJtU8!j4Lrg&Rn+C>NO&?<%-taRX+WSTyI}#|`P?kO^)O2;bY`%5Va;6WQ>*No zMadjUXKUcgXCtv&8W77>2IO2`o;(LY1;%UL*86o1ToUr2JiEWmFxuH8bvu?B6Rj|g zV+N*eTF4{Axd`a~UBkiNoovGhLdbvy=*&x|rlbc}Q+W;;`u(lk#0}xS6uwQoE-D{T z)IZ%Iz=!-F5hc}Z5CC4)SlO+l4`F|qFZadb9MC9nA1}<;oX%v;l`xMG>aWjFW3uS4yqcN zArqO)Q1;TXKPzEus38hIC|I35x~gq((tEn3kQNCAA2m5f!|FRgLA{OEA8!E{#zvzy z*^Y@G*-e`!&pW5(J8)cC7rpa|DJ{cDsVqWduB+{-cq&sYokC^f8_>(QP{GQF*=IK?+yh-05ixE)PBM%HBpFldYXK7qVDIbKy>)LdKR4 z-DFSAF?f5>CUN-R$Aj9GQb0S}vc5O6CS0vlD2+@;M7r z3y|TzDvfq?Z!PYJH_h&=$)q}Gn7)oQgsW7?6nYvH> z=?5in%-TYd8>yLOmZRe=J%mQ3OE|Ibo0<7G9%ZsL9rC?X$`E%++-beZw6XT`w-=1z zDalDIoy6yROVV+cSjIQhnoS{mMyjGsi6(Q7T%26SBFRf>T8_?J-I7o~+_GTRdGEP7 z6?phCVXHB%@TQhzMo#@v(rOA`tBirIxiX$!D6OVTqwc-(x$be{Kjg2Pea1+z!+ibv*hY;VkHMaV6iX=jG;b&=}6_bdT&SphY4<8 zGn!kn^jmqip3bC_OXkykOpv$Vyq*s4-vOrd7$`Uj6Wp{j_Nd!(Dq_O3XUI%d3H~Ow zBbIFiw}d*pRv^3QKsBdi4dv5nQAt_L0LUGMZyGr8vz-HQJVTkkcm(t73obwizbH&F z1b%Bwn10S;#I=D#({93toye8=t|Z)myuXsK7{Sdbs*q#UdlO6>Ct9j95_;dslmAKM z`fq^9g#GOphhuhR_;@7(X}qA9vV(3GYsfiTDr@i;GpDcMn{f@RQhp{Joz9qyZ8d_} zXG<^9Q#t8Z_sh&tS}L8A895k1RKD8S_h?2?R(A;5u_?HEcFQR+2UQZmt5pr@L$wTh zdH0E5fzp=EWXtGD^k;AF!5|K9FdzR`qu-Q7h3$2?05HO0)Xwhs!>CAY3fBJ5@=|kNTK$P3CFqq1ZMeQ%uKoZeEL-Pfo3asb{sKm{GXOGkFGk zTt*nfI5GdIcKifBY_a|cXx>QR%^)BE}m9rQADj$&> zl89Cuhem8g2A`Y(H3ukd%s3PpvNfhV&^6Lc`h2kJc4sPLZ(LznrF3UZc4<_>ucl`a zq!LzAIgV{-@3>;wQSodfB0B&g!(zeqemb_!!nl;=N#imV$4e+HlahYR-JTa--2vC> zuj*w+OPf?RMjSGJ@$FzV41>ru25b3w008La&d)el=CzCXI^SSvGlTB!p$pV* zq}SFYv{q)<&K~lEEY>}GCo{zc*qH5^+K8_>7zLLaMruaeW_U+j)@V(&ZlZXq<$o^^ z;TzVqtjY=0@7a~KWG9|gN!J|OiJvvCm_nGEabggL;ConUbHvJQS@3>(S#mwNKkcq z<=B;|d~%d!Si8f3_Rz7tJYed22z`fyzP@wT;b%zxM7p+DgDD#gCA*Nr6^^*~lNB&* z;|8(>y#q^)$kr9Q{-*E6=r%E(yHHNj4D|?c6yUbf4}UEYZ$`|8{!w+o&5Y{ zXL==nK+H^0Jyw|bfitd&gXTZhFSm6cLEUNWQF5Q%H^r!Q(oTcz*npwebL|o~E+vnz z+t?eMSnAX6H7-NW594*k7PZ*v z<2+5??-`9_AvblF*WRTqYp_&jbV>nNj3866*B}##W=>Wt4JzkmR2mP2>^Vr;1q*cd zIWAvsEl>=UvvTw=>VSV)APLvMO4w1)&g!2P##`O|U@cQ~j!udbL7p*^+H;${%d=QY z!!zp~Af!9MU%Mv-mIIUKVYRbk+e=6fX>IF z&$Agz>LK|Jz?hen$V%!w1EY|Vp0M;6dSV|N@WfeAuuEUNQ>QuyhK;Z@y-!Ny4=gSHyJNn<7)%Mq~Lk8gRwSKy9f)+{PSz zcb73Bh z7wt+}FT=g$mXciz;?(06x;FEYnLY~Zx8#s9vUyVLh-hek#kK{q);#F&*&o}YHCGPS z^$;~VKLa&QSyw^@YdSo!`TRylMXcAoRuyC0BNXzv7Hre>t|yYE=@naLsw!=WDJ8ut z-26RKppXF$u&pelwFOa%gFwI2_^vNPgVV}Xy=8_vT5Dh}Z3zhjk?BKlszR?40QePo zNEo1KjPiT-B?JXLEu&E}-6i)ZU1@<`VJ5))TpEjzA_RnPL={?dw%T`;2)O*UwTdv< zmZUU;oPXnhB@dlSvvG9^!{g(T)&@~q!p)tlSZXrds5ZtYcmGDXWJq#oQ{?QsuCi1I zq2R-$%k@eY9s62N7$T`pX%rM3@Kh&V7Y1OEtWYi2eyUM=sF_YV=p^CGp}X69=bQRsC>RO3SsV%oZ{4J_*@9GzG{J@LA9u=Ae)}e=YZu?u1iHY-%ThU_=zZ*i>-@> z5>NU?_pgdywl7%+-{kLDsikUx=lClAGY7%6vBLj?iX( z{_`N12}*8P*te&&b4}7NSWwVi{9m$@&vPU^m*xL@U>$^>GS=9;gxT~DZTrVj(5Su% z5SJCM;vbglmEg$QZf5)Ock9H2Un=>L?)Og@3;_s{u(r)4hxvxd@Q_Qzza&s5ypy5sA5BxgKNN~TgR1y% zWY_uJWv+Dc{Q86+efCprLrHJGv3CkHUAqb~JI<@0raFuIc)_?F~-)-cp3U zD5m@P^BBO9gafwg34KRw$(a|J4$Jk{$KZ2-m)wFmdDrgq6HJN&r8Dok)t?6dgwO~O zU85PY+>yF!fnvIk`2TcI{n#U9v$PR|5^1{HnZ@dod^tLE8F`n%BO8E<$B%^n{B%hb zX$$?Bo-lu9!s~g~l|1-4z*A139kUuMN$D3I8XZF$TzaF{cV=%y{*&!&`M3c5H!m<( zeTD0$cGI!pS@Fy7z<~=xJ#>{nxIOsA%c(p$5|=;Y5@fXzL~R>4 zQS(71`GPHp{uf&YfRu*nO8HaD0JR7-pxBo8Vbn}`E_>B7UL;XCo2QH1TyGAZY|6+ju zrMmPp{ROBf=z`wy>y<$ji$5brdy1@m!oj9eFIlI}wv{kayN!g<{5Jh z#LNg4pS#gd)LOhAU_@(v^3Cs>4lDj*AQ6MU^dDS@Jg*{KUi>@^HAL=&`0AG>S7Lc3 z9}UHG0J^8`QG!_5RsOeMCC&l$={p>^Oq?>@t^}RXxeLG8^Z0oPN!$egH@GXDN7S!M zFZczpezD^tO}i)tyJ~Xbhy&0+`*~#KU(ou!W3-ED^th_;6qafsy&*I7N)k+`OtiA= zdtnZ>I!K2Iv-F;W5`|F7;cJJYTh7Q(`b+Wdk+@FyLK$dOsQ+LXLj`x}Eh9OdpCLT1 zgbt5Ccf}f=$DZgSi|fbeLe#t%UqzlfIC3a5`|qP7*+i1>I~f|PFa3-E%q=dH8J2d) z=mg9YWEeq;qKk-x=?5Os9`RZW8kS2GFryn1z;T*GYBGWfk9 z+vw{i$m1u(nJ4R3l7>rN<;dm2io_%Q(sq*SM3s-1BvE8HzxVaj0ASLf$auc@y=o5l z&)h<`0_NO@*k~MKFoqOW@WSfGe6G@K9E9==LDk^I zP?-_CBu>h28ayDu2zOssSe7{s8rSJL6gSh8>~v$dHXfnhw)u#9qCe$3M?Kzub6H&} z>DtZYHkIi)29cN+ApD-H?1o_Vg!flPYsq3OSxic~*H*6w4!l80m6s1uO#WxwApZS5 zh`O4;+7vj9E|MkYJ(`ytHwNKux!MPJ+SRG9ILdh}>=-Q_j;;`qA4z>M9FMCYirRwQ)2 z|GI1cl(m;7oZfmTuVo^A|D^bC{I4pEPN7!DzyCf^eg6+1JJ9Tmh-#_Hz+1_U_M^a* zHf1pAfgo@r)3L8443Wiix-?K(y*hfj8Ekz#pH7SA*A;Fw5+P=wspZsAdm2a=OT3;H zaJIjk=VGMwD}5)y`THU8<7@eo93cO_#kCar{4pq`LNYKbGp;0}yA>HvX7DOzd2o^M z==s?x=h|fL8AXm#HA+Uf7~}S5KSTeUQxMg^tf1T%1Knri=r-2t9vF3ML0=sYWQ9KE zb;odpoGP8_OitiapaiIMfE)!eL5-Q{p>A@~e@0OHALqM%a(t)0xN_Eq{j9=}CY9xb zC8#e}Pr`mu<-VnSMFM>f}Z>arvInL{a0NHo|{y86>&MxBX5?Hu9{C= z8_P(+X_>qE3?B@7G%43TxvuE$mN3D1XTwnYhD3S5Xx}ZgfwSm|VtNBw*E>ZVV_Mp> zh~uur+~oyH_cMi{&|Rda!7PH3ux|>&C(&ZdH`bp$hkN|b%G&=Mm(Zv3sddG>`o_Mr zMixc!e7CTQgeGN?(RCV?wZhBtLm%s-UvFyfAKQ`Vn_zyzsWG(}v%HmTZ!{7r_NuI6 z*U!}qWSurRiQG=l0j(@FC`RgdY1{DTag^^)(~a;yi{Sm=+cYjL_$zIhcM*~k(YHkX zS4(<=(l<5UCziU7fgs2G6d;xO{TiO<7py(ELVWfWPmMyt3?Ya|k-my&A1BwJXS<&! zI`e(sWH05QEU$mvX~}1E>VrWsuaz}=kI=#2nQ9dYy$Z{nhHW_a3D_eoQOE_fl(=e# z(y~1SE}SzcG4(&Ci~euq4-~)us8L_iY{~SvFKbu=~wjf7qT59er5W(0!zPj zZA+DFe0lk*DHY=BOYxBM&UGENU)uZ8>TSe56ejew7P6R2qN~)>zm>YrP#MGqf5j_} z9CV`dhot2Z&>pT%9<-n(eEgTk`t9Sthj#hJ0GKrL6Sy<7Hl^EFe)DCdRM9lX&RNc1 zYxsrn4*ehMs7<#YE!E8@EvB>ziIw z+vDdE{&to9FeiVz$}UdF->$MhX5(*H8OoCXpL3NVJ=8zD%D!8z|9BUu>wnW#_8r!b z>iI|Oui+~DA*12{e_vU`|FgcbbHM+?S>`SE$7(=b(dbRxzjiUXD=zf-rlMTbiL^W4 zAs@p1D18Lg@sHLeR3?4=GL(lJpz`$(iVN3EQjL>@q(NuMWcT!f)H9a;H^q^WKa0(1 zpbJD7nwUBl{a!dV5ocF<&jC*!?$CMt!Vh~Hl}exG1AapBU@<@sEC}>jlII61n;v z3i5*f1&Zc-3k@YN`u3q5)c1brW=5k1f2!w&bvig5)q#BIWy$Z%=Gs;c4gh{BXPK0=nSKyDWNBG?h{X1(BYYdB`b zyppMcOE1gxQ^qJ&h2>=7oy3pU7D3aoifKi`AS>nG**te7CblPX%_-j1g#LJKVu(NR zqS@q<80tS{UlhL}uDZL9j$X9^P+5G-{5J5awq|wJb8MIQs7rN!y!wOcy+22X*JOrO zXxx+}ZJm`5b831V?Q`W_kV+V|`kRc2aZk5)^)4~EiCs8>{$uB|(~9Y%dm+98zb3lLlxpPm51Iy){c z!T3HYb($WSB;`wvyNP-0mCi3vvxwjPC+QGgD25S}-ZIply`@md#4&xq*|RU#HL;-e zTP0R4vxz+S>dMJe{o<($_Bw&4?_cmw_}2gJN2+5idI7E;34uyUhvoSf7eii7kfghX zCq7)AyfNjlSh>44DfiyKf+|fX;Cd|w(@D{La{lj3@F(05{_RJ4rO~^F5tH@afaiJ( zur*Uw)iRH8@d&!yZ@->*Rf}Kd^z`%`a2Pl!g7VME+f5Pdup zHV*M`v#97KLA+E-QeJlpY3+45UPM;m4zgSK_=c5VZ!6Ve+FDw8vt;9?Ma^j%eobw1 zZrJ*I`tiP7zO%uvQpDW6jyfxNzE2j`EI?xX`{8mKwKwEGyAtrH>wp0Qz58hUrtIsC zbc=6Cx)Esg>&7_WaV`(kUD6OdF|IYfid-5l25RUD=|=T>^BSSBQK{izBrdqgVs9%` zYx2ga&NwnF_qz__UnLho<3qiVztFkz;||k*Gtr7|oGplK$Oxzgk+Cj(txb})Oh33t zTU%4;z6^KnPv6dT{=NWi?O(L_|3yy?)N4Z?WJ>{qmpfMbLQS=YflG!R@0J@k#(Da( z9bw~>s`cv%36C;Rd&MpKrbe&4=x#SP6U1MM5&lcwgbI5h-@ces%bVd4ScJ9eq(383DZJo}}!bxSxu&`;WhNcA-ilfM1#<&V(vwD#*yRj=zfIx?zrjBL3Um-^d{ zgEpxLU1?1#98Q-U&H>MpQCs9q@e6RJztCoRagFGXt$P^LZWh*+&NdinfEOHwKS9^m z^M+y zV)7`n%0TwJ!10<>_Y1-88YB8%QWmwp=o-R|Q6MC+3TAtdW>9Rj8I(Q>d1{f60wRd1 zJpwLoogqFI-@o7oJZLW3=gne+ttRHi^xn}Ly{I_X*57SvEk-(7+ zyW{EdpuM(J)6Hma{0rLr&qJWGE(Lw`u5qF7@4)+r!g+Xn2}Wh}ptyMx#d$e{lghlpO(R^RN{-Qt4=`Uj1{EdH+grJMG z>lgj6T!eG{fD!%$F#wJFLOwD{OH}aGFNvwX<`Fx}t?C@+CnFKZSexl#XUNSVhWJry<~nc&uRIb7agN#-J?_Uz#;LGLOmGO{`m6^pQI(8s8*ki zW8{EX)h-0G+!TDvt0r_9nI~=4qECF`F#352%nSJilpm<{odfC;HP6z!;b?M62RH&7 znWxyVN651%JdxQruoAk4_!55}<3fUg|3?!KTc)=?^9dSoaXC14JU%$BZ8{}&uiXtg zmH?pbM+OPF&mFm(1F|EJPflz2C*3J1)&!b=n?_QR&j+V9yr)%73FMR~20^$pJKM)6 zt|u?g0jB_rtztMWXrM9^s8Wn@T~dfU8mL_(JqNfa1lpY8gy2?<;xis3p}_8_Osn zz5UxKBK<3KO+@pdAx}D%iHw5zA8rPHsW8K3#x)5dcjK+hZnWX(Dy7#Jus_k_pv?>o zPRj*%r}Zxq(IX%k>0P;wP5ejGF%9zx(UhO-mjP%a8=mXVr|W~~fLmc#JTv(qU&k3F znNM`+%6X}6n#B{$v_K$#E7>70mNzYUJk@bvI5gB6h#zXSm80~b6=a>7@+9?Jq)v>P zt*ZFd5(n1lfjae64JNY&THKI+IOVv&ah~ZwZB@IQ{~+2R*V0_?!x2pV&-t9B7k((CFdZPoimH1ow(Q|b%WHY0Vo8LzH8F6m|&{jo1 z{>Ci7C0O4)2RN|)gG<~UnojlH1P%cJn$NDt*u2BdnX}p*>-}SwrXW^+_fJjRTbQ&F zGeYYfezlBL$SgSDk2ADi9!i)fL?Tw-h5R{JO@F)hzx-T&^UI9GEx$V0E(>`4OYh%4 zCS1&p^9SOWIsO-*#b>t9e$oGjXqF!`RsDh(a3y?mGs$eeNGF^aiCn#Kp8b+I^i>sj zPN3L*z7d(1=qY_;yyCIynYjtV<7aA=3fVM)I?gr-DWqyHbJ^riN;m*VTm>*^=Tc(dAGkgtuDAR&LW)Re0^4CJP&9?e!%1 ztHT0O{f<@q178(>-jTgXgnqkAyJO^=+9bOgoj%#QK_|J(gz4ccvEj8OndW=ZFB;4T zf-)1>vk9W71{&^mM0cx4HU;+HVVx$2=t6kwDm0Q03PFA>ZYp$bEhS{cR+2Uk@nMg> zWePdloW8z0rPuJAx}VSUx*x*!+O%IJZMVN#hI)W$z*Q_7c@W>MpOt?8Du}JIZ=>C& zF`2)dNL&SuC)_tx-L2#$XxP(3K-gH&Ce0l&X-XBLsV>@8$;n~zF?C}hKCSpFBntlw ztcx?0D%#gtCf=K5GowP~3=4B0S%WPOc+0oW267Tk1ZFWV7iE)TCC#o}^Q^uO61vTi zBJG7^<44V+1Rnz0U@?uT>l)%|6@4gJDHzevweJZl2!k_@%WimrH&3&RXPR2JcuU;M zo91(jx@Vqdt8gH^cT;ED$_q^rIICSW7F8%mDNY`GB(VYDCl&Zjz!Zy@2$HY-1|YO6(W@ND@_Ve+(63Ypc8fGiWR zN?&hJZljq&ZZ6`n87O+_L3a=~qZz1dSuF;n-|pcalMAn<*X>K^Nnl)+MWQCg1*RSN zHS9D#(@ubcb>+gIq8~<#`-V95a`+|dxJ2eVa|-HK_(6i$FWKZWbGysxWW^Z1oX1{_ zDU-5yW^_n0wXvr$+gk)^>ye7<#zwbG8)*9#-Jzq=o@M762aJ%;L&|%2ZkI{ zHC-(jRToMfFbK_h1gZ7(@Y!=sAMGP}s3^**ZTj}M)~8~1VKj@iRc3UD?$!!zkHz&% z9cnqJVc@(zj_ma5UEA`TawThyC)`y_F)EgG3YXeE{IoTdO&xP zF8wt0+E%zd{_O0qP0?%1zUOVV^{X*gV<7~fS$k4GUei;(I9hRMk>`m`AX?*X8Gs-a}q0pA5W5=)4p^e*;c*04Qxv;`0GR<6;>u% zDGj_#NFmGlQE3MZ3St*m+ZuAOJR)Lq--tWE09DIqhPvc)zwtt8%>$Pqv9nwZ!_W<* zV4b79y*Z3o?@)@GsVjqKc1WFK`{H(7@|PYGDrN&%%jIO_CpIujjLm?ZTAMWA9<}FJ zs%%ZlF{Z1^KVgGhN37H7&9Po(+dcWeL8rpx<3P}8CTNNTs#0WlobKysh94dQ)*(A!MLu#rW}?u+Ayld}e?8q}ntkr;&gk|x zK;6_HJ(7KTPC&&!q4N5a!G+_HE5@lcJMT9sN1hl-c^TYh@8cr&2W1jdiz?o^IbEsQ zsRrp4nG#BIW9vh~nj*m+y3G*z75YqfsF-%(QFjRyXwoKs*$PKJAXzO#Ca}t6M|o9Q ztj++JGKsGgNaEMgHBXML&a&;jU>TW__v@5DN~8hiX45}v(_a+Sz!yqKM0gX=LmotR ztPkDySP+$YHwqe$(6u~i`LJHZey?M`YWkSI?Tg2UVerIDS)1Qh>F@f|> zidacLbdYG{G!+1DorzN}nG&Kt9rxu%4XE|?JB9ED8L&Y(|SoCyZt#3`E zu&b^?UO>^?ug+C2PWS9nHRDn*XWu%9Q~h8p_)z(_UP}$@CWxK1P>_v@5Cn}Uk%OW> zWe&X|h=86{Z^WH{?VSk%1rHYSJ_dqAf1o?}qJnKIqft|tQ6ev|-{cHNHDw0fxh zw%Df68h5|Jtv!-x~knfgxF#$z*Uwju8`OplTIgdFWW1R zKjtDsnseHiNH6Gs)}&@73D7gJZNHTUlSgdO<-1Rw5(MpCSnTc*#48(?aKj|KQ6nKm zNS1b|kc458agB}UqcpNm-DNEs(@`Bas$~l1ofVC|{n93U6c%QtKzr=kY!$}gGW+26 zm{_W8hXcjHG`UDVsT6@My#)mtCxHB*V#a)rEMNmj^J@a1>e^+Z$2%%_P^uh{AGa7h ze+aiw_bQ@pTl$R?!kmH~Tp&6fO@Ye+6d@nW`C(OQEDv35x!uDt_FdwdsaqJ6?+ZJ9gc1RX%vH{BD!kR$2>YY zqz!rW+8zzR{ApsX2Jb$9mo7;Ps%}e^a$H@Eo_<5-t9Nl-we6Kz9p@cazuW~~vPrA= zvr;(^nc4^}bCs{pEz{d8cbcw0d)5P#S+lr+64B7HyQmt9sGrN+LU$HA9U+#z3_#9R z>wIl~%2Ha;+tz4??4F7{6ZzBobbVvJqNh2FotCb~rdG2mhHIMd#1ZCz z?(^Ps8bn$tu%}_A@XO_XQvcXHy&Sa|u<{nAP%o>*!w6VEA#?g;@QN=i29WRRy0b$X)I+tF%SQ`jt z-SR=hd2iSWbW1PD-#+J&AonUkIt@Xz0uP{{7OqFmnoU0iWv1&X_ciuj%#V_~W}a~w zB6GK%y?e`9vhHGuPZGzk{eB@A)sb>VJUXy7(Uh-GL^gZ!J7-4SlJ$Iw`^zn$X_>J# zYm+Y(%Q9@U-3nqWB+iX`JBG+R^7{B$!KR7xibVveMhoHMYCDj5DoMk}gutKj++XR) zu^}9tMZGJhiok@WbXjaADNh$T1JlRLf~k*|iU(^pr_SC<>Ym!1%1UHffU39G6z1tAtl&KrHAoBojKxXG>hFpM(+S=2A- zVNNXLkiw%9WjtD|$|{~022lQ+gDAqCvDT4&BPpD2p2Q>t9jQ-1O*V&z)<1r(cpMxnPuQ_b*HeC?pNA9 z`RBo&^EU6QVRRPyA#*N@1)P9~GSe4PVxElReW>@2S%I)lS>@*l~Gh2+GM{Wl{DGbt{ zZ!JWGxt4mX=g$U+vhTq)c+--L*xJ|2j5Armhmk`^|ooMF1RP(4cc~{8=9PmiguP5gp+FLi^}nSotaJN9B(#K|h3jbpDHn|M|zi zW5GTwa&qTKtr@@vK!U>=h^s$rd^lNde>jc)ed{9!j8@$D4pI=b&@JPNJ0Gmy^R!%#`0Yjjp8*oP2+H{;dl#P+oN`PmQ#7)tc@ye6rGJa!W;A_0;Rcea{nf0R-Q*)q*}kyf zX0K|l5rWN-iJGqIO97|;mZ8XD%#hewrfcfvcONZ0b{S`xP9llEZuENLKbln$39MRt zev@B%hk)FB@kSbb>sLWYu*dbK;W9#$>HRh#;qI5h+nzP~5$c3?$vPD3UVxo56qhqk zsv{3fK(r#(=3>84(*XfE2qH&uscB zLFjQ2)NH^x&-t!HP_JLi0z$yVF*tmx^uuzd;$rTuw#4+1BxM5SvbnAxGQPs}3giaf z)orq9>WWvv45+l`Year*YlPXEsA+WeA`BK)?kVQj8L6pd+ILTG#nxF|#LnEI1Ud|g zd2CH%h6e}o~K-rcW4%rl0{SvL!>$A@NvP+_rZkcLkkTBfmg-gQOH|spK^%S);I+4uDBFEDb|PZcfxsdtxdoD^F*DV^$*Q?U<|L0O zw-m==`-Pvra8jwy?n@~%!32!#dKnB%Z;r5F$zt&a7h}b4qrVUuqNI&!@=^j=M0muYsJ0U4OqwcqltV_PC!WOjP@u=sS>{ztreTkJQ?syG z?TRz%RGDY+?HhvEC#P6T`l59tqXgv%?y%s%p_ym1OG!TMa5rW7nO6f6w{lE7w|gQD zO@u6rpCmBHzr>MZDk|#dNtz|mI6|)V=~_2zYG%{P3mck{DzD86+m_LeV~dn{!)=`|%n*q;u0%j-Hv$Vw1MWP1&1fbh zJncsLMM(#C3NMyctnFe!s&rYn{Nz?q=;JA1Rzv7E5qZ<8d527H^+F*Q$F&;?BDos_ z8v299Zm*1rt!s7eZ-l1c?SFch5f^Q)Nvh%hwRlkkinIeROI)@o1cT?mZV0vN`_89Z z?ungMpsM?ClqAz5nn0101r`W(kP%eft{YdsuKgfj13E|bewXH|K8AfFP~GaYqr$SC zQR!~tYMxFBNm@-5QR3%Wop$XY3N+okS*Cc7oqOW+@?6`>E;su?;Mh<%j_S9hs&Kyi zQix}{r56FW z0S%F_ohM-|OFv7@tB|&aNR){XBs13^HT~B`(r`k%UCuYp-^?6MsJ4OyuTiWY z_ZgvaDb$`FFL?rDagB~Ip-~Gzeglkd?_VpGPvvOuy+|B+aw6RpEUZ)z&vP>YWris3 z9?bNfjtf5Xx>4eva9TRIJ`AU(8qeF}-zGvwLDa&|q}y-oxNnlDS5G{;u-V~*ajdJu zt-b`^yvxAoGyPJ2-V5dSwrF+;Dj~m93Jo7Z(7?{08uX>d2RcJjN^M+U-*ulM^-}Be{B7;ZNnqyHFizbO z{RU1}NiAja{DKw{Q>^H-L)}{0w^Jz3xg$TRFh{ksKa!1Q6`{wobSc~FFPg6%w5 zCk}$K*JGwDv4>ZuKqcyvzYM%k^7OGQg@(NSdh{ze_=HgBy>=zexU$OTpRw;FvSj5k z7(wxEJLi7$jO*baAB~#m;NP{qPcCeE5~NJ9gv88Ji48* z3vybhPv2(Vgs(BCMi2-4K4XKQIKGP;<9U-x^yn^qoq(94k&Trf?Hn^~bPe9r(FiwFI_s!WEzh^cUwi zRi%u^ZvYq4ug?ri=E-zP#e5($xhN>Bnl>xfGkEm_r2bl8!D35VW(A|aQVqt!IJvm> zmd%wVeU&n#7;?aT!|5@b4%|4~IR&L)K5YDQiG%w>TR?6;GA+IzUmc)yHkM+-WWpZ; z!mYJ|obdcrL7*|Jy6fncgIf1A`_884x^|y!#;n9RFXk>yJQ&Pk@dfvUktjJ$CSZ!1 zy;gTy^i+q|VAxQsD%*-f*(HY!b17o2z*mF0YuE+W-dcfAt-7eRlmxHYPX*dd2^p~D zvi)N%+-!FG`}4+>Md38_tW7yA=Gmg1-nM5;kVQ+ugsNraI~6w1)42m8_iTb6)>Yw1 zzX)BY9kZ~svS2s;&|M(j)YdB?CVgG(b(BpbZu{r3hbkL(1_N*FXgx1{nqhG#!U-O` zt}LkH+`jApLR^)ST54uTFBt+t)I;p57k5JB;P@cQ{7msMXxAW^YE5<+f9)|&YHnF_ zZnjZgbVRL83~sYZIUp-7xWGiQyBQJa{R%GSBfaLuGKDvW^HO|z>u=mMi^&?PG&w10 zLKXe+RaO!M$6&_j%fUsE+y=_PAw`_eJ5}s;^Utp&$2i0P}lX z%4q19s_}})b~ABHbQKhHm#E1qP^mQLZD^V(r|NKP zyJtFQ?9Q_-GcNYglL`?pOw(#ajbBzSSVu*A6@%w)m~d9}jy?8|6mZ95di7ZmVkHd2 zX6;tb5;j;$>LpA&E1Y557vQ}^9bba{>z0;cN3mM1ze7u`QB*vE}N(bk6#S<86!Z^4JS8=1X z-rS?*i@s_aVI|uTB5zvX=Pk8G*4`=j`h>6Ij#in67r!eJSMgs`hZ#!Vg(1EAaR1EQ;ARJsCMpr&#%=jsasTd##7#J$b5;N{D&$RQF2# z8bb0jtpaFb@_crV5(cTL_0$S8YP+$sh=hkWq>;zuLWWP(?|uWIX$u6tP!D_aq zY?Fld^^skcn@;b61%x@!5F)m_TDG0p$9&Ys#^G6AX{!}1b@6y|Npg-_3XPy|1S_JD zp{YG|y~_0(XJvU&MJ3y*@_ftIrq5H$qr` zLSKWrsaMB`2m3$e=zh^HyJq&>S1|JA;aA&IiGim!ynF3wX?DWNimx;b1LsV-pqJf_-R z&}cL7WXF7NT6U|3e_Xew7>cg`bj1gpfLz8?JQQb35IZ!lHb#v*bM^xSuXy5+YEwtZR(aQaec%$gye(|5ywL&wVkD=Io4Z$jQ6USIe$TV3x?}b; zT6b%sS6^y4B`>PQ;KB;?<5MJ(J?usym;w`YF08g4pgUc%dNllg2l|5>op*7-7l=$xZ12 z!-#3dUtdI{s7tu{ytsC83>8js#%2I}(!wUnwuAfGkBv{JUT&3i(j++}kx`NtoRLe& zWON}KYfMtxUq@q;k)c3CT}7d2_!#Bx%DAL=@;(*5Y?`sMTSVL2#gO|2x5FNlH*$%5 z1Gw+zm9ULFgzOt^raCr-=BdwjQNPYSIKaRhhIDE^=w1Kc^wa@B=IZx}4FKTpu$=#! zp89Tx{+~=w0m9b<%NIBcT#zT7mi&{Q9F1WFExC9>egEG{`sj}s%M}u#ZR6-%X*BrLZ~w={&_ zzFv@;MkExVd?M;D-C!2;^)tHeflDRrWjZzYJ__n@_xk)a{3q#;mYCb&MYpcau-U`i zY3Y>#_QRvamKzGqi{cf#5~sj{b1`6hy!-nO_c*Lyo}Tj96TbY3C>?R_ORmo!kJ#wt z`YzfGn23q7cxh9oWX%*jwLK_2jq8#_SM%CPpioGyFL96-*w!kCaf?g3-~kFi z-y9y(P`$aidN*poALi{J|8Dv*Vh_JB@g|g)ChG_B+2#nf4&H4k0x9pn2 zh1lFRdsx>w;^GD2_uK>@fG3Rv*82Hldi1M*OJquZIT>M|350Fb$d^{YmaIajnxQ0% zhr<)H`EVAyNeo=9*)hFfX5gKjGvsmAcvaHRny#E-t>Yp0GHJ`bWaa5|@P759_<(2_ zqTE8+K!*4l-Z@Nm$hMGa_i!Alw>9BYohmEh3qvRbs5UiiX4ETH_m$sU8^vW+d(w*Q z8uV%Rv@#d^cKB1*b|f;*+AjN9$O=0Su4GQ=;32Py+ms4;6_ZoSS)cPtoGL$8SG@g8 zLb{E_M3=r*4%d~w`XNzJ>fpo20(l+rJSn;+?IT(twMMdgA|A-D*G&VmCF3Yu@(T>V za7{Z{r%RPYK@0@fOk4{h4jk9L`yulK;%U>JPB>Lg^zo#SleT>3Gh~#N{mMnsZA}&- zs2E0iK!NFH>X32LODV?MrXk}B5}pK4>Thd>?H|o3m%!LP2QSToEx=m7nh=jIKk`?D zPg#NSUd^Zrs}o-&DRz&!s=90OwqNFFO1U!addJQ?WEVm`%LkYJMrPbwOTEVBHgmWn zKou2_cT?P~1I1E#Hdlt%Uuo{z3L+xXk2K??p00}sXqA<^mWVJtD)+9Oe`)-7xGZWi zUMODXu}`z=2M@lJ)j2SUTHU)7o>nD>s+ zapZXN*Nr%CZo4&tYZS6@pfOsTG8O*~0CrU1MJ2gj*{MfVw*?1>(8~z0>LD_@kGG?1 zDd#7m?^ClZi!4rD#thBGe9DOD2s{70H!xP57g%A}#xl^?_&~Z-L#zq`9vC09*|}3l zxg<%HfKY_Pf)6PGD>0D&32EDRmKHRh{RovZ+{&jdoxSHTPIF> zNdMf@ISM!ltbbOBYJ#WdL6q*HWhY3*rCh4_-aY1TaNBIXmAAGNx_?QkYbraNDwP-- zS|or^?A`7lyKHQ&uCCJd!|Gn{y#UJ_^vh)FTQrr&R-9>Rib(C33s5rFx(#;cJ!?4R zfVq3puoyY@{920{e&kVc!^DR)in(|rq6g{6Fb(fjO|WIl{tooeCA zWd}8BMqo{fO@yX}oL5vg$mYC-%-}dTzq|4oQfCl8WgiBe zYfgd&rkav7bSRB6yGhnI2K9nx`a+l7xeyof^%DfFUp{A2>`apJTpHTUg5%%n z)$NU@h$%q2;2o+&!M&6Jo%+MYvjoVRWv5IL{G(-bv>JN(2nKL1BcUGpR+PS#h}0^&1$hsF1nJ06Bfj*%dyMU8wy1Obtt)Fl- zzW1|!+10+Y!PenhWfm4O{e6^Sr7EcFq~GKa9x({>{fwuwmO?t{P&=323u@&Q>RoXM z5hF=Xh1tdZE*Q#~NpF?;7HHUR;py!hQywwg6+IRn-`9oHiJr1^%8$YmeY8q3YDk@xK=y)uF87wU{D*+ivRU$Q~9!I;8K<8^1Q!oHJwhA}DH zmulZO1{X1KMgluW380}t@{bZ&9-AcJ~&GX^Sd~RhQ}5}eW9C{cR_x|%U@Gv z^Zv+ML(JfOZcdO*dH#nve{lu%_&d}ZsbKlwJSP$%UV;x}T_v-wh!-8)k7w7AV#3~P zh1`j;s7H}B*|}s9=o{?r;g9N2UfE(Usm4ux)sKP-f4b0Aw8+Q!4%@Tf7b~cZXf+%j z9WaQBL5Yr|zQ89+tTS%eCQC2rIGS5MdC@E_@L2O^4loh&;KO#5Y=-lOdEFx3ey;R= zHKLz>8CJNx#3iObyK~09zeI2#?b7H)@EMp=f2^ZS$>WsC^(my~_=EM6bD)v?ElqQ? z1vv1HSG7v~g-TM*pu4N{j>j^R{i_osP5Jv`1#nlr;%;6LkD=h?2cAoAF_oh zuH@GO-Dl)Q5qG+eVl!YO&SOx|^NHiqG*Mhq)OI$~tD%KLbn)$d+J;J$4;dI2v99CR zcWl~wb8qE>cfU-FFXi#Fx|5wr zH=?nlzQGm5?!+Pf-Y^({(6d2}pYU@ekh9z~T zTeaA^%-i`KBf&h!i=jDX^8+T!P+|M6ASwp0Hs}7daZ!0EwkC}?q9(DOkWSUc^$ZK77&?twWC^rdF4kv@+zNx# ztG~upr3|}A>}!MzkEh5Oxj8Xw8lQL+FcK(t6FbGg+dFvMaO_0oOfSV6Zv9!YOh@BR znaCXi`z0%HcQ=n|0%T@zaFD=^ERNffkg{Bz=HUtDZCXex<%;sLNg|GsC>o`?&(`Wi zdPM8CN6J!aLR<3*8|OM3SGGYxBX(z-R+O!zJsbu{`ibwJ$Sqz_oF2f4C{*KW6`c5g zNpa!mR3m;g61(0AgoW{N%)fr9SUnVyI>nTNug-o!DkbWo=0D!iG<9XoBxMg&8)I$z z!9taKD&*3D{k`Cw9eORze(_F&-PzW`eZFY>irJspGI)PB79Y+j&M0P1K_|B{T0v8n z%W?CWJ}u^H+0TupFCmQ|o;5A#HRW(i^OJI3k2{VJj%dtR5^!`Ms3-1s?0#&S4n$Q= zW#nbV`)dlxtS%r`cN=dbeAg{BO81|ARV%^yG<(-bx-a9w;YHPR-gvL5qE8dDQpBnS zR6ciuqpHc*FvdKEs;|K|&_&C}9_!Zpxl#{l)d|5=EzXI=mH;h(AP1+p<=`~PBB=T~ z(5Qs@<>lCSotlhA;!`Jv&89Bc7mh;HcJ&g{=iTCD?{j-)H`{#qxYQGPgMOQ{SOVgb zxLOQDKDVx@^|>OZ=U({>b`ouBmX;Y8nQWzhrKdziql`-HL4O0F*7n27_nA)_Cl>`J zBh~fS6}+gl;)Vt?xglV0be@>AvQ94qqqI83wOQBlI&Ld$D-VX4`wUlP%4pg7x8lUO z)anP*%j5GJ()85qrX9Oua-+dF^mpZUkin_y#>7tcnksE>8}_>voGz03rwwxAi|=M9 zgu!^nLvT7IUSvmcE>F5UIGRU-vB)}zcE!WrAzpw|9Sr7cs$z#^+^QaQD0n&0%1~%@ zlb9LE%v#4Fz&kq6fdoC4VnBw%t!P?R;A=^F+$VYG9EoL|fddp=#4| zuwJ(UL==M{1ksy~c9(z570;_!+W#Z86Ce@&ZMg@G;XFPZ`@GYMQNEqpyRIE*?hKn zaC5@QX74WWxNhqojJqdfe_-Iqm3fu-A#NYd_wU5ei>nWj$N&C=)_}ux#D`&0KWYm& zT!Z!KaB z+X8-0i>;Sb;@%4@`px{ofkwl>MnU+OuiR%Y+&0e>Q#e;Rxx??nb)#RFV7Mg=`Ua4_ zlTxm`rR)CLY>Ka9xw3l*QD!hVFo^u9YydKD@?iGTw!ghuaEe&wC4xG$`1H6!;<)0H zZMxJ~<(iE-RAwD(aaw1wH99hC7_w0vk+FI)^3{ID%E>hS^`kZ0h>CXT+E`d{`Qoao zZ7BQG;R&iWSn1wk^h8_MF_|4#{*(9$lkE}t*3=3{<~M<9jd5Co4j!Eq9|$r|h;XEk zAc-xbn;+`orK^V>Had9$`8s(nLY(C0=FDIdo(5GgCb{V~E&&h*#PXuWo8LX*Y#N#Cg;PNKbZEr16Jrd3j{`x!^0E35gptGQZB4%K)A_O~e$s z^wP^aWPSsj3kjshuw+I$Jl2k~Z|6nbq!Uk;uim3Li`eeZQ@1>jVKX+3dp%jjKzQ94 zEhw1|q&;J)zUq1m-xgHSqlGvS|;ux~~QhLFo?5Yt> z*rj$#CFy47vfWpev)>lg?G$ag$oCYTpg{E%(QX6- z`yy+b@F9~k$ZV0#Ne7!lJ6{Npyv9_U zHuJb&-OHoG$A^dBS)&IAyB=iBt*u&|15cEBghFI=D|F1Ija@3#Nn7Q4d0Y+v0JqV0 z;MPtcJ+pQ$UPdEu^&6l`d$0a8;O7wC{d(7Fjk<}6z4NqvJ8tgTbuX>t9n_n($t*-& zjRLC~9egxQD+Vqhp`&$S!3|%7cUmi;Xlzz7j$Ut{g+dA$^iz$q=sSn_{+>o%y~d|= zJQa*G7cR)CyEv$a?C;wJv?X=}hiMo$E)g(@hzcd&%~;{SFdjlzSK-*NcU;_VrEbvu zOrn}Tu6~~Z01eB$y!hU(&9L&;5da<5DZP8wW~%}|Rd5(G{GUqBd+h%?a^8FY26Emv ztbZMHUb=44e;GONA6w+HyFb`M>A(6r3LD_I-6qrO?%S+SCCpd9Z-2%$L)D^FDVLsh zx!U&dK)C4W=R^8LXGDs~%pGA^JBme2-}g3ta_#57e>P;(qBK+J{sFnokv!gmvSf9R z1LW}c766CnCH_}J<$r}P|K=fw1dB^<9uhA4Der)POwII9fv~IJJJyxRu9oX3H`w;FK*#UCn4~VzbN^8$4+yJ|&Tah7IO~AqWcr2lb|;&}7I1lO_Q$hmOWW-w z)x52+R`~Akh@8W<1QMS)5{^o+R$4ltGD)+(+r;70DumF0!T=&vpB9=Y*s=GwegOQQ zaJMAMZk2Oi#t^{P8(&Z#D9~(`!%1 zjZcXVfNYw*DeiS5@4f->1QdJ#ra)_$|6TmA_cDLf=Y)fES@PGGK=z78cgMaLvpfptcf zJ`<->O~Ur@u6SJiH$W)egM%B$RQOw)|JFt49~})k(*6e&bLy$uZHDa65(&^$X&YDv zWK)*%o_;bPvcT{4?^CzDSjl=rT)*hc7}zIb!Qutdf&g6y{MFB8j@G2sWp-7 zt7!koa?Cla_#5CJFO43F;V|C-AI#PMqxOUUn$fwV9Tt1qPVU9->u%65HYDC_o_!Si zz7!cBbIc2Jn5|Xan_*`Rm5!)w5O^cJ{~xu}|D%)c-*+C7ym*K#(f-uuh1g>vg3cn; zlqxWkW~T!&3-GK9u1G1I??tE^>_4dZW7qw!JMsL(IWD`H??|uWy z_bLTtIqGJx|ERLqo?oIw;NX5{>pRqK!P}5 z@sE=#!&o7G9qRj}E^JCbK*NmQkCe49A$4?c81fjsPj)Yl6&^~3J2qKKAGvvpMzB&p zl}EqQ7ySnKVyRa47oN+9f~CAa4&Nu`y%L^PyUU+~^66qKN$j-IfmBcG#i}qGt-%go zbBy>8&!zv?XsMoN@JIE;!r}iikV(0bY-3d zo_hM%LpAYwmMr>~-TE@lvVG+oDzP>|;(#Bta9*%Jt5{!x7Sndn!R?>}?e438?J@XQ zUyHz5JvY*&{BSHQ&?1z%%NcCTUNKX;B6H?Ydhd?-^c(wCi@fHKvyHXotRej~D}Fes zysO85T7tB2%LVRQ(`nH%SDtj*hOwyRv#kOCl(mX4$S+WNQ_k0;456V~^YY=wEUATt`9&fl6ty_BoiEq9ApMkHCtPXNh3o&a`bANr*~Z99^**KUM%^%^v?*S>{s<{dF(PFq$3UIL*G>k=$EWl5{8PV$_ z!;jL7p2;r*^bbi)aZLQb)q4@Q2?N$4(>dgPxM-rH{QJCO))>GvHTx}Fffo)LX%J^+&P%lD}p8BBu zQ(b%9+EkIQd+DVj*}k&%x+-yOXmmwiAw^dNzJH3gGhW}ubwnpH7XJ6rvfPya`R?(n zem;6G)_B6ClTQRMvCwOlSFiwc!%^bNW60z!b-Nkegm^SLCianLOsz=kFD<4PHnk(e zCI}!Bg3GoE`$8viBVfeFKP-i~?eiVwcl?9t_$d3qF7Q9-pz!ybBnokzzFhDnPDGyq zF20j%Z?}67Q^qfSxKb2^uFR_Ir|wgU!u5Zyz^%s*qk{hB?)7ip?*0M~z|f0DF;LG! zb_OGnYDPbh^1%LvLU=u@aCCp*=+qv#p^nU~{pZ2Y@sVoCUCc(k;@m!)7C1w72Bd;Lb}i*dvE#%?c$P0-T+k%hMkmvgT`Q zOsrc*1t|}E+r(kBY7!|_ZxJ#XwBK;g2>)k*5Fd*qAIYv=rd~Bf{{F9x_Fvp%>|bpa ziQdg)!(RfqM%mHTq|D1&M)kL|;8dgzU0ko`F;gFR6s>{uY(4s_Lx^V48K+RWCmasi zacnVDHZUk$Ihov&rMFXq+b{7P-Jg$%J%}QvEf2CIe~p0Y@7KY9An0?r(}&GjsWkoM zDJ0UcQC=d}@Mr-xI)5>1Z10pOZC-`r(53A;t!TaU_b+=aP`1Hy5#@9y;*WdIR(AWW z8^M2`YuP%_zfGW*ml(`?aD6&+>|m@8wiTIKz+0O?UF^xhOjQ5nKBOqd)Aq%0 zb5J0)nazQVS$H1ixyO@29fcpoQ_r>EJODu+%{l1U|JeQVke~2*!03JVy!XMaoPN?~ z8F+K{W2<-ZJH;pygqtgEV4WUkNFL_6o8J2?zsY~IbN$}+?C*5|{+_Jw2e^Tjz@+Ao z-VEy6}Uen%8bq2frQmBXV58a?#IlH9;$I7%tVm`-4tg2SNPfW|4P;Ksdk zQ9&;_9hB!3TvfkRvTi(&=bM*XKle*yxtC!OT&qy&6w3*Zr)v>?Eey})h}Wz}Cmz)# z`UuG5fS1e}VfwG@ggJj&3?bEK$Vnu+44V#p11Q?WdoxL<5qF>GPJB$Uzt{FMt6Jf9 z&aWMAZop@o_fO@|_YGrGfPu$vnm@mA+w7$d&)sN6Nbv8n3EAth=_X$m#os$;RNi^r z)NYe&tYF>qmU#U|cPfQ93163YQ|j&uZNf;Dra)?~ObJ4Bu-8V3LZTT6H%N>JSL@bS2siSW9V*e^5BhtJlDj>g|i~p1kJ6N zO$ZZfHb~X*l{Q~=fb}(_#>`B?E<_t=F3`WeIMCB&UK`!An`5=fXv$W4Nr~*NtD7Vs zc|*FF?+MB4-C_YkTmY_{PC+3tiZd)XdcBtvqOEj9^#X_{Lu`2`KAy8ix>~@>K8ok?Aex1!kuG~M_A$QW#?b`MulXlcjy!F=L3`lU5iDjtEEhIM>EvZ^3>q>^BVxT zt=n*kCDg-a%Z&%aU?R~HPR?Qp1>HLJ1N0reG01y4p3g65to}7!9p_K|w%2WXNySSHa_Xh@k{s#DgK3jLpk7RI;X8ync;4s~+{h?|L|99BNbBo!1 z=6;R$EUuM%d5cNk`ixfgQhVrxX2lqL#6ng_?b_RY`@}!E&W@j5ao}ZX9pd@GayiU! zd>5Ep$s+QL=iRd@S3?7=#CjoQll2@))d}f&{#`^h;-;*A-`LJvH};k~=i` zgxBtDbq(n_srJDx z#X6wkbRZ+hBA`)EoYaA?(!KdAnkwFV21_Wz4cozl-oNyfJm~WgHt6}jFT--(@&z#A z$Ha*%tgY-ysPM!UeW_xt>tTj*0&yRFd1Lt3BhF~NOU9S+_8El zcVZ|W+hz1Jz#S57h3oOQdI?^mBt1)5xCi3Dm8%F(#ZBf9kwhQlZZSmi#}|0IhbBnZ z+EPNCJ3GT8n{BEYCX-X;E3qs?ohTRlvu*8`%p%C|iW8^w4N~Nlu5)lRw(Xo+J(pb9 zY`x%>@ar&P2#XQU^fV3k>F}6$H`)b09@pc?NQ(dZYuTsRXXy&A7k{?PTD^{p6E;WT zRN{OHCyU+0+WcgUa#1p;tQz$?4Pao%s^@0laP)d3_ zsoHP1FSnk4-RsZ`L>bw~d;_HZ%5x=O{CUJ=hOmTGY*pr)Ok*VLb4FF~I?pP^tPo5b z`@F{SfPDCPaj+&%H3|y)>Nw2Vb$kJOcpc1}s!O{lj16*uPy1%Sv$4N((fRb(_~q>8V27 z#jL!y%0#;5KWXb14%WMDOa}7eog%waVw;%|KP4BDgL+TH_+F|+9a+ufy5w|LEWYDh zY?p`EEe7Q(RkXP7WX}Teb2jEKM;3?CC11H&7Cu>|&_zDQzIl^$tEP-VI0+W?oDMPP zuM_oNl4%oq9W^pYNvdRf`si#L0pF;d0rYfL+iM1+AAA_8(EKQ3REQU0G!kQ~(A%g{ z%&mN%DSmyNv-GgEc zrV8RwF57Hm1ZUxrFH5R4hep8w{F=F7|>3#=0Abl5-ZEZ^bIyTgh697Sim?qt))=;!Hi68_&|~c(qc~&*8D*_Y0%@ z@^3&M?nd?(r?8du3+Nh$Z(It*rZ|uPIwXe~;9N9|apCLE))Q{5IwQKpro$hDSLaW! zdKIsaO0YQ_?4J61W1;sJLPwBxHb z?-f3-fD?z%awyZQEgX{`wl>kkk{0YbK#b}0gd#99&-%TQrS6>m7Z$lDR2hVYQhJ3C z;iQc`G~8;9St!cboL||;)s59E-X9T~du!ZUf51V-K4rqF^Y$9Rl-DZ|)%FU!Q!1q5;)D&X}o+(3>FC)1n8 zNinHu^_a2fz%*S&-B>6#exywlN&9#-q;%YB(x=wOtO{vd-w-J4?T9kQcAjp1!t+nw zuUjrZ^vEuJ);lc~8SDA8at|NQ87JWxlH_(nas9ilJS`0vc456gT7R6tYbXJjzh~O2 zp?5vC1dphDb*<~|pSOp#osW5C;hBp!mYaAs*j7w_;-pZ>V=Eimw z)JquLWT_7svR(Qh%~9)AqLe+z$%?Rd>FxMLY3j5I{)ce=|D6VWaDMS#xn%Jb@so4g z#kfXTy7wzplAgR0?7ln1=3csc+(Y4*>k34}Z(BG?5m>US8ZW;cUy7zmKiKJ|0&ZQU zXK@;BeXoVb|L^(0N8o?2%pCnGF20&*G9$lRr|{J_vFh^tXHlH`4i-vyR54So-PtO# zGb1cPulP9V@90K5Xd|TZqdI2{o1NA()L8UXvdVNzK}udkq4G&ihWAtRA4bj0o=1Y{ zU`izo|I^85-Wmf?W=D}E>DVC#WX1<~L#9)`5j5Datiqa!vYG@c&$`N39!Q`0&&_*D z>tb|+KJ9QFaC^B%tIduVqÐW)D1WU+?_Xe?y7+_@vPNQZ^DEjR)u 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 Date: Sat, 13 Sep 2025 20:56:14 +0900 Subject: [PATCH 452/527] usercontroller edit MyPageController remove, UserCharacterController remove --- .../demo/controller/MyPageController.java | 35 --------------- .../UserCharacterImgController.java | 43 ------------------- .../demo/controller/UserController.java | 40 +++++++++++++++++ 3 files changed, 40 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/controller/MyPageController.java delete mode 100644 src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java diff --git a/src/main/java/com/scriptopia/demo/controller/MyPageController.java b/src/main/java/com/scriptopia/demo/controller/MyPageController.java deleted file mode 100644 index 5fd05149..00000000 --- a/src/main/java/com/scriptopia/demo/controller/MyPageController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.dto.history.HistoryPageResponse; -import com.scriptopia.demo.service.GameSessionService; -import com.scriptopia.demo.service.HistoryService; -import com.scriptopia.demo.service.SharedGameService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequiredArgsConstructor -public class MyPageController { - private final HistoryService historyService; - private final SharedGameService sharedGameService; - private final GameSessionService gameSessionService; - - - /* - 유저 -> 사용자 게임 기록 조회 - */ - @GetMapping("/my-page/history") - public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, - @RequestParam(defaultValue = "10") int size, - Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return historyService.fetchMyHistory(userId, lastId, size); - } - -} diff --git a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java b/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java deleted file mode 100644 index b1243165..00000000 --- a/src/main/java/com/scriptopia/demo/controller/UserCharacterImgController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.scriptopia.demo.controller; - -import com.scriptopia.demo.service.UserCharacterImgService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping("/user/me") -@RequiredArgsConstructor -public class UserCharacterImgController { - private final UserCharacterImgService userCharacterImgService; - - /* - 등록할 수 있는 이미지 저장 - */ - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/save/img") - public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.saveCharacterImg(userId, file); - } - - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/profile-images/url") - public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.saveUserCharacterImg(userId, url); - } - - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @GetMapping("/images") - public ResponseEntity getUserCharacterImgs(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.getUserCharacterImg(userId); - } -} diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 1f5f64b4..1e1f5bc1 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,8 +1,11 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; +import com.scriptopia.demo.service.HistoryService; +import com.scriptopia.demo.service.UserCharacterImgService; import com.scriptopia.demo.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -10,8 +13,10 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/users/me") @@ -19,6 +24,8 @@ public class UserController { private final UserService userService; + private final HistoryService historyService; + private final UserCharacterImgService userCharacterImgService; @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @@ -62,6 +69,39 @@ public ResponseEntity getUserAssets( return ResponseEntity.ok(response); } + @GetMapping("/my-page/history") + public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, + @RequestParam(defaultValue = "10") int size, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return historyService.fetchMyHistory(userId, lastId, size); + } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/profile-images/url") + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.saveUserCharacterImg(userId, url); + } + + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @GetMapping("/images") + public ResponseEntity getUserCharacterImgs(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return userCharacterImgService.getUserCharacterImg(userId); + } + + /* + 등록할 수 있는 이미지 저장 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/save/img") + public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { + Long userId = Long.valueOf(authentication.getName()); + + return userCharacterImgService.saveCharacterImg(userId, file); + } } From a75b87510b22a29896b66b68a420b99d433788f3 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 13 Sep 2025 21:21:46 +0900 Subject: [PATCH 453/527] usercontroller myhistory endpoint edit --- .../java/com/scriptopia/demo/controller/UserController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 1e1f5bc1..c545078f 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -69,7 +69,7 @@ public ResponseEntity getUserAssets( return ResponseEntity.ok(response); } - @GetMapping("/my-page/history") + @GetMapping("/games/histories") public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, @RequestParam(defaultValue = "10") int size, Authentication authentication) { From 31d7470978b109174697672f04582a77df1bceae Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:09 +0900 Subject: [PATCH 454/527] feat(controller): add endpoint in UserController to fetch user's acquired items --- .../scriptopia/demo/controller/UserController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 1f5f64b4..786884af 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; @@ -20,6 +21,15 @@ public class UserController { private final UserService userService; + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @GetMapping("/items/game") + public ResponseEntity> getGameItems( + Authentication authentication + ) { + String userId = authentication.getName(); + List response = userService.getGameItems(userId); + return ResponseEntity.ok(response); + } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/items/pia") @@ -64,4 +74,6 @@ public ResponseEntity getUserAssets( + + } From 9bded5f0f1c635668c85451fae267209d579fc8e Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:10 +0900 Subject: [PATCH 455/527] feat(dto): update ItemDTO to support user item retrieval response --- src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java b/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java index 07cc57e3..f2b416d1 100644 --- a/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java +++ b/src/main/java/com/scriptopia/demo/dto/items/ItemDTO.java @@ -26,7 +26,7 @@ public class ItemDTO { private Integer luck; private Stat mainStat; // STRENGTH, AGILITY, INTELLIGENCE, LUCK private Grade grade; // COMMON, UNCOMMON, RARE, EPIC, LEGENDARY - private List itemEffect; + private List itemEffects; private Integer remainingUses; private Long price; } From b822998a3e2667373f1f89f0de33db3e6bdeb1fb Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:10 +0900 Subject: [PATCH 456/527] feat(mapper): extend InGameMapper to map user item entities to DTOs --- src/main/java/com/scriptopia/demo/mapper/InGameMapper.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java index 3b2dbc83..2871a7ae 100644 --- a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -20,6 +20,7 @@ public class InGameMapper { private final ItemDefMongoRepository itemDefMongoRepository; + public InGamePlayerResponse mapPlayer(PlayerInfoMongo player) { if (player == null) return null; return InGamePlayerResponse.builder() From 56a2c9fc3cad6c48ab4733e52307f6775e07342c Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:10 +0900 Subject: [PATCH 457/527] feat(repository): add query methods in UserItemRepository for fetching user's items --- .../com/scriptopia/demo/repository/UserItemRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java index ac448e67..2ff90ea6 100644 --- a/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/UserItemRepository.java @@ -7,6 +7,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserItemRepository extends JpaRepository { @@ -14,4 +15,7 @@ public interface UserItemRepository extends JpaRepository { Optional findByUserIdAndItemDefId(Long userId, Long itemDefId); + List findAllByUserId(Long userId); + + } From 89f944260373fbe533f7300dc64c9f954b899db0 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:11 +0900 Subject: [PATCH 458/527] feat(service): integrate logic in ItemService for retrieving user's acquired items --- .../java/com/scriptopia/demo/service/ItemService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/ItemService.java b/src/main/java/com/scriptopia/demo/service/ItemService.java index be4d586f..30aeb5ba 100644 --- a/src/main/java/com/scriptopia/demo/service/ItemService.java +++ b/src/main/java/com/scriptopia/demo/service/ItemService.java @@ -29,6 +29,7 @@ public class ItemService { private final FastApiService fastApiService; private final UserItemRepository userItemRepository; private final UserRepository userRepository; + private final ItemEffectRepository itemEffectRepository; @Transactional @@ -192,8 +193,6 @@ public ItemDTO createItemInWeb(String userId, ItemDefRequest request) { List rdbEffects = new ArrayList<>(); - System.out.println(effectProbabilities); - System.out.println(createdItemEffects); //아이템 효과 정보 RDBMS 매핑 for (int i = 0; i < effectProbabilities.size(); i++) { ItemEffect effect = new ItemEffect(); @@ -201,7 +200,8 @@ public ItemDTO createItemInWeb(String userId, ItemDefRequest request) { effect.setEffectName(createdItemEffects.get(i).getItemEffectName()); effect.setEffectDescription(createdItemEffects.get(i).getItemEffectDescription()); effect.setEffectGradeDef(effectGradeDefRepository.findByEffectProbability(effectProbabilities.get(i)).get()); - rdbEffects.add(effect); + ItemEffect savedItemEffect = itemEffectRepository.save(effect); + rdbEffects.add(savedItemEffect); } itemDefRepository.save(itemDefRdb); @@ -234,7 +234,7 @@ public ItemDTO createItemInWeb(String userId, ItemDefRequest request) { .luck(initItemData.getStats()[3]) .mainStat(initItemData.getMainStat()) .grade(initItemData.getGrade()) - .itemEffect(effects) // 리스트 주입 + .itemEffects(effects) // 리스트 주입 .remainingUses(initItemData.getRemainingUses()) .price(initItemData.getItemPrice()) .build(); From b010cff794a4e7a5020b76e6c374a9c1b94e9eb5 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:11 +0900 Subject: [PATCH 459/527] feat(service): provide user item retrieval through UserService --- .../com/scriptopia/demo/service/UserService.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index 1434b437..f729030a 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -3,11 +3,13 @@ import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserPiaItem; import com.scriptopia.demo.domain.UserSetting; +import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; +import com.scriptopia.demo.mapper.ItemMapper; import com.scriptopia.demo.repository.UserPiaItemRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.repository.UserSettingRepository; @@ -27,6 +29,18 @@ public class UserService { private final UserSettingRepository userSettingRepository; private final UserRepository userRepository; private final UserPiaItemRepository userPiaItemRepository; + private final ItemMapper itemMapper; + + @Transactional + public List getGameItems(String userId){ + + User user = userRepository.findById(Long.valueOf(userId)).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + + return itemMapper.mapUser(user); + } + @Transactional public UserSettingsDTO getUserSettings(String userId){ From a4732a3d962458198b8d2a53b82428dff56ad7ab Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:39:12 +0900 Subject: [PATCH 460/527] feat(mapper): introduce ItemMapper to handle mapping between Item entities and DTOs --- .../scriptopia/demo/mapper/ItemMapper.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/mapper/ItemMapper.java diff --git a/src/main/java/com/scriptopia/demo/mapper/ItemMapper.java b/src/main/java/com/scriptopia/demo/mapper/ItemMapper.java new file mode 100644 index 00000000..7664e0d1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/mapper/ItemMapper.java @@ -0,0 +1,70 @@ +package com.scriptopia.demo.mapper; + +import com.scriptopia.demo.domain.ItemDef; +import com.scriptopia.demo.domain.ItemEffect; +import com.scriptopia.demo.domain.User; +import com.scriptopia.demo.domain.UserItem; +import com.scriptopia.demo.dto.items.ItemDTO; +import com.scriptopia.demo.dto.items.ItemEffectDTO; +import com.scriptopia.demo.repository.UserItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ItemMapper { + + + private final UserItemRepository userItemRepository; + + public List mapUser(User user) { + List items = new ArrayList<>(); + + List userItems = userItemRepository.findAllByUserId(user.getId()); + + for (UserItem userItem : userItems) { + ItemDef item = userItem.getItemDef(); + + List itemEffects = new ArrayList<>(); + + for (ItemEffect effect : item.getItemEffects()) { + itemEffects.add( + ItemEffectDTO.builder() + .effectProbability(effect.getEffectGradeDef().getEffectProbability()) + .effectName(effect.getEffectName()) + .description(effect.getEffectDescription()) + .build() + ); + + } + + items.add( + ItemDTO.builder() + .name(item.getName()) + .description(item.getDescription()) + .picSrc(item.getPicSrc()) + .itemType(item.getItemType()) + .baseStat(item.getBaseStat()) + .strength(item.getStrength()) + .agility(item.getAgility()) + .intelligence(item.getIntelligence()) + .luck(item.getLuck()) + .mainStat(item.getMainStat()) + .grade(item.getItemGradeDef().getGrade()) + .itemEffects(itemEffects) // 리스트 주입 + .remainingUses(userItem.getRemainingUses()) + .price(item.getPrice()) + .build() + ); + } + return items; + } + + + + + +} From 7fb65b409610d9abf1c2df1ae8bb1f284151b98b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 13 Sep 2025 21:46:20 +0900 Subject: [PATCH 461/527] feat: create inGame-dropItem and add itemStat when game is started --- .../controller/GameSessionController.java | 13 ++++++ .../demo/service/GameSessionService.java | 42 ++++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 1569d4b3..ded8895b 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -111,6 +111,19 @@ public ResponseEntity equipItem( return ResponseEntity.ok(response); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @DeleteMapping("/dropItem/{gameId}/{itemId}") + public ResponseEntity dropItem( + @PathVariable("gameId") String gameId, + @PathVariable("itemId") String itemId, + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + GameSessionMongo response = gameSessionService.gameDropItem(userId, itemId); + + return ResponseEntity.ok(response); + } /* diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2439076d..89876075 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -241,6 +241,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { // 최종 GameSession 저장 GameSessionMongo savedMongo = gameSessionMongoRepository.save(mongoSession); + // MySQL GameSession MongoDB PK 저장 User user = userRepository.findById(userId).orElseThrow( () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) @@ -256,6 +257,7 @@ public StartGameResponse startNewGame(Long userId, StartGameRequest request) { userItemRepository.save(userItem); } + addStats(savedMongo.getPlayerInfo(), savedItemDefMongo); gameToChoice(userId); // MongoDB PK 반환 @@ -837,7 +839,6 @@ public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) @Transactional public GameSessionMongo gameEquipItem(Long userId, String itemId) { - // 1. 게임 세션 조회 GameSession gameSession = gameSessionRepository.findByMongoId(userId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); @@ -847,7 +848,6 @@ public GameSessionMongo gameEquipItem(Long userId, String itemId) { PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); List inventory = gameSessionMongo.getInventory(); - // 2. 장착하려는 아이템 가져오기 (기존 ID 유지) InventoryMongo targetInventory = inventory.stream() .filter(inv -> inv.getItemDefId().equals(itemId)) .findFirst() @@ -858,14 +858,12 @@ public GameSessionMongo gameEquipItem(Long userId, String itemId) { ItemType category = targetDef.getCategory(); - // 3. Toggle: 이미 장착되어 있으면 해제 if (targetInventory.isEquipped()) { targetInventory.setEquipped(false); removeStats(playerInfo, targetDef); return gameSessionMongoRepository.save(gameSessionMongo); } - // 4. 같은 카테고리 장착 아이템 찾기 InventoryMongo currentlyEquipped = inventory.stream() .filter(InventoryMongo::isEquipped) .filter(inv -> { @@ -875,7 +873,6 @@ public GameSessionMongo gameEquipItem(Long userId, String itemId) { .findFirst() .orElse(null); - // 5. 기존 장착 해제 및 스탯 제거 if (currentlyEquipped != null) { ItemDefMongo oldDef = itemDefMongoRepository.findById(currentlyEquipped.getItemDefId()) .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); @@ -883,15 +880,42 @@ public GameSessionMongo gameEquipItem(Long userId, String itemId) { removeStats(playerInfo, oldDef); } - // 6. 새 아이템 장착 및 스탯 적용 targetInventory.setEquipped(true); addStats(playerInfo, targetDef); - // 7. 저장 (ID 유지) return gameSessionMongoRepository.save(gameSessionMongo); } - // 스탯 더하기 + @Transactional + public GameSessionMongo gameDropItem(Long userId, String itemId) { + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); + List inventory = gameSessionMongo.getInventory(); + + InventoryMongo targetInventory = inventory.stream() + .filter(inv -> inv.getItemDefId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + ItemDefMongo targetDef = itemDefMongoRepository.findById(itemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + if (targetInventory.isEquipped()) { + removeStats(playerInfo, targetDef); + } + + inventory.remove(targetInventory); + + return gameSessionMongoRepository.save(gameSessionMongo); + } + + + private void addStats(PlayerInfoMongo player, ItemDefMongo item) { player.setStrength(player.getStrength() + safeStat(item.getStrength())); player.setAgility(player.getAgility() + safeStat(item.getAgility())); @@ -899,7 +923,6 @@ private void addStats(PlayerInfoMongo player, ItemDefMongo item) { player.setLuck(player.getLuck() + safeStat(item.getLuck())); } - // 스탯 빼기 private void removeStats(PlayerInfoMongo player, ItemDefMongo item) { player.setStrength(player.getStrength() - safeStat(item.getStrength())); player.setAgility(player.getAgility() - safeStat(item.getAgility())); @@ -907,7 +930,6 @@ private void removeStats(PlayerInfoMongo player, ItemDefMongo item) { player.setLuck(player.getLuck() - safeStat(item.getLuck())); } - // null-safe 처리 private int safeStat(Integer stat) { return stat != null ? stat : 0; } From c31c3278c9408fb32715ba0d32de3d016cb63caa Mon Sep 17 00:00:00 2001 From: KII1ua Date: Mon, 15 Sep 2025 10:16:39 +0900 Subject: [PATCH 462/527] sharedgame infinity scroll --- .../demo/controller/SharedGameController.java | 8 +- .../scriptopia/demo/domain/SharedGame.java | 2 - .../demo/domain/SharedGameSort.java | 7 ++ .../demo/dto/sharedgame/CursorPage.java | 3 +- .../sharedgame/PublicSharedGameResponse.java | 3 +- .../demo/repository/SharedGameRepository.java | 112 +++++++++++++----- .../repository/SharedGameScoreRepository.java | 5 +- .../demo/service/SharedGameService.java | 110 ++++++++++++----- 8 files changed, 181 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/domain/SharedGameSort.java diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 97ac9eaf..a79c0c86 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameFavorite; +import com.scriptopia.demo.domain.SharedGameSort; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; import com.scriptopia.demo.dto.sharedgame.CursorPage; @@ -43,12 +44,13 @@ public ResponseEntity share(Authentication authentication, @RequestBody Share */ @GetMapping public ResponseEntity> getPublicSharedGames(Authentication authentication, - @RequestParam(required = false) Long lastId, + @RequestParam(required = false) UUID lastUUID, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) List tagIds, - @RequestParam(required = false) String query) { + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "LATEST")SharedGameSort sort) { Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); - return sharedGameService.getPublicSharedGames(viewerId, lastId, size, tagIds, query); + return sharedGameService.getPublicSharedGames(viewerId, lastUUID, size, tagIds, query, sort); } /* diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGame.java b/src/main/java/com/scriptopia/demo/domain/SharedGame.java index d7bda955..f3cfd849 100644 --- a/src/main/java/com/scriptopia/demo/domain/SharedGame.java +++ b/src/main/java/com/scriptopia/demo/domain/SharedGame.java @@ -26,8 +26,6 @@ public class SharedGame { private UUID uuid; private String thumbnailUrl; - private Long recommend = 0L; - private Long totalPlayed = 0L; @Column(columnDefinition = "TEXT") private String title; diff --git a/src/main/java/com/scriptopia/demo/domain/SharedGameSort.java b/src/main/java/com/scriptopia/demo/domain/SharedGameSort.java new file mode 100644 index 00000000..c55acc89 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/domain/SharedGameSort.java @@ -0,0 +1,7 @@ +package com.scriptopia.demo.domain; + +public enum SharedGameSort { + LATEST, + POPULAR, + TOP_SCORE +} diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java index 3608d0da..6e2d32a1 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.sharedgame; import java.util.List; +import java.util.UUID; -public record CursorPage(List items, Long nextCursor, boolean hasNext) {} \ No newline at end of file +public record CursorPage(List items, UUID nextCursor, boolean hasNext) {} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java index f847e3b2..c7a70fdd 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java @@ -4,10 +4,11 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; @Data public class PublicSharedGameResponse { - private Long sharedGameId; + private UUID sharedGameId; private String thumbnailUrl; private boolean isLiked; private Long likeCount; diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java index b90507c9..b9ea57e8 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -19,43 +20,92 @@ public interface SharedGameRepository extends JpaRepository { @Query("select sg from SharedGame sg where sg.uuid = :uuid") Optional findByUuid(@Param("uuid") UUID uuid); - // 기본(전체) + @Query("select g.sharedAt from SharedGame g where g.id = :id") + LocalDateTime findSharedAtById(@Param("id") Long id); + + // 최신순 @Query(""" - select g from SharedGame g - where (:lastId is null or g.id < :lastId) - order by g.id desc + select g + from SharedGame g + where (:tagEmpty = true + or exists (select 1 from GameTag gt where gt.sharedGame = g and gt.tagDef.id in :tagIds)) + and (:qBlank = true + or lower(coalesce(g.title,'')) like :qLike + or lower(coalesce(g.worldView,'')) like :qLike + or lower(coalesce(g.backgroundStory,'')) like :qLike) + and ( + :useCursor = false + or g.sharedAt < :lastKey + or (g.sharedAt = :lastKey and g.id < :lastId) + ) + order by g.sharedAt desc, g.id desc """) - Page pageAll(@Param("lastId") Long lastId, Pageable pageable); + List sliceLatest( + @Param("tagIds") List tagIds, @Param("tagEmpty") boolean tagEmpty, + @Param("qLike") String qLike, @Param("qBlank") boolean qBlank, + @Param("useCursor") boolean useCursor, + @Param("lastKey") LocalDateTime lastKey, @Param("lastId") Long lastId, + Pageable pageable + ); - // 🔎 검색 전용 (태그 무시) @Query(""" - select g from SharedGame g - where (:lastId is null or g.id < :lastId) - and ( - lower(g.title) like lower(concat('%', :q, '%')) - or lower(g.worldView) like lower(concat('%', :q, '%')) - or lower(g.backgroundStory) like lower(concat('%', :q, '%')) - ) - order by g.id desc + select g + from SharedGame g + where (:tagEmpty = true + or exists (select 1 from GameTag gt where gt.sharedGame = g and gt.tagDef.id in :tagIds)) + and (:qBlank = true + or lower(coalesce(g.title,'')) like :qLike + or lower(coalesce(g.worldView,'')) like :qLike + or lower(coalesce(g.backgroundStory,'')) like :qLike) + and ( + :useCursor = false + or ( + (select count(s.id) from SharedGameScore s where s.sharedGame = g) < :lastKey + or ( + (select count(s2.id) from SharedGameScore s2 where s2.sharedGame = g) = :lastKey + and g.id < :lastId + ) + ) + ) + order by (select count(s3.id) from SharedGameScore s3 where s3.sharedGame = g) desc, + g.id desc """) - Page pageSearchOnly(@Param("lastId") Long lastId, - @Param("q") String q, - Pageable pageable); - + List slicePopular( + @Param("tagIds") List tagIds, @Param("tagEmpty") boolean tagEmpty, + @Param("qLike") String qLike, @Param("qBlank") boolean qBlank, + @Param("useCursor") boolean useCursor, + @Param("lastKey") Long lastKey, @Param("lastId") Long lastId, + Pageable pageable + ); - // 🏷 태그 ALL 전용 (검색 없음) @Query(""" - select g from SharedGame g - join GameTag gt on gt.sharedGame = g - join TagDef td on td = gt.tagDef - where (:lastId is null or g.id < :lastId) - and td.id in :tagIds - group by g.id - having count(distinct td.id) = :tagCount - order by g.id desc + select g + from SharedGame g + where (:tagEmpty = true + or exists (select 1 from GameTag gt where gt.sharedGame = g and gt.tagDef.id in :tagIds)) + and (:qBlank = true + or lower(coalesce(g.title,'')) like :qLike + or lower(coalesce(g.worldView,'')) like :qLike + or lower(coalesce(g.backgroundStory,'')) like :qLike) + and ( + :useCursor = false + or ( + (select coalesce(max(s.score),0) from SharedGameScore s where s.sharedGame = g) < :lastKey + or ( + (select coalesce(max(s2.score),0) from SharedGameScore s2 where s2.sharedGame = g) = :lastKey + and g.id < :lastId + ) + ) + ) + order by (select coalesce(max(s3.score),0) from SharedGameScore s3 where s3.sharedGame = g) desc, + g.id desc """) - Page pageByAllTagsOnly(@Param("lastId") Long lastId, - @Param("tagIds") List tagIds, - @Param("tagCount") long tagCount, - Pageable pageable); + List sliceTopScore( + @Param("tagIds") List tagIds, @Param("tagEmpty") boolean tagEmpty, + @Param("qLike") String qLike, @Param("qBlank") boolean qBlank, + @Param("useCursor") boolean useCursor, + @Param("lastKey") Long lastKey, @Param("lastId") Long lastId, + Pageable pageable + ); + } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java index 6eb3bd27..bd759bde 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java @@ -2,6 +2,7 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameScore; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,8 +12,8 @@ public interface SharedGameScoreRepository extends JpaRepository findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(Long sharedGameId); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 0327d717..aa815d76 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -8,10 +8,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -105,7 +107,7 @@ public ResponseEntity getDetailedSharedGame(UUID uuid) { dto.setSharedGameUUID(game.getUuid()); dto.setNickname(game.getUser().getNickname()); dto.setThumbnailUrl(game.getThumbnailUrl()); - dto.setTotalPlayed(game.getTotalPlayed()); + dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); dto.setTitle(game.getTitle()); dto.setWorldView(game.getWorldView()); dto.setBackgroundStory(game.getBackgroundStory()); @@ -144,47 +146,97 @@ public ResponseEntity getTag() { } @Transactional(readOnly = true) - public ResponseEntity> getPublicSharedGames(Long userId, Long lastId, int size, - List tagIds, String q) { - - PageRequest pr = PageRequest.of(0, size); - Page page; - - boolean hasQ = q != null && q.isBlank(); - boolean hasTags = tagIds != null && !tagIds.isEmpty(); - - if(hasQ) { - page = sharedGameRepository.pageSearchOnly(lastId, q.trim(), pr); - } - else if(hasTags) { - page = sharedGameRepository.pageByAllTagsOnly(lastId, tagIds, tagIds.size(), pr); - } - else { - page = sharedGameRepository.pageAll(lastId, pr); + public ResponseEntity> getPublicSharedGames(Long userId, + UUID lastUuid, + int size, + List tagIds, + String q, + SharedGameSort sort) { + String raw = (q == null) ? "" : q; + String trimmed = raw.strip(); + boolean qBlank = trimmed.isEmpty(); + String qLike = "%" + trimmed.toLowerCase() + "%"; + + // 2) 태그/커서/정렬 전처리 + boolean tagEmpty = (tagIds == null || tagIds.isEmpty()); + SharedGameSort effectiveSort = qBlank ? sort : SharedGameSort.LATEST; + + boolean useCursor = (lastUuid != null); + Long lastId = null; + LocalDateTime lastSharedAt = null; + Long lastPlayCount = null; + Long lastTopScore = null; + + if (useCursor) { + SharedGame pivot = sharedGameRepository.findByUuid(lastUuid) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_PAGE_NOT_FOUND)); + lastId = pivot.getId(); + + switch (effectiveSort) { + case LATEST -> lastSharedAt = pivot.getSharedAt(); + case POPULAR -> lastPlayCount = sharedGameScoreRepository.countBySharedGameId(lastId); + case TOP_SCORE -> { + Long max = sharedGameScoreRepository.maxScoreBySharedGameId(lastId); + lastTopScore = (max == null) ? 0L : max; + } + } } - var items = page.getContent().stream().map(g -> { - var dto = new PublicSharedGameResponse(); - dto.setSharedGameId(g.getId()); + // 3) 페이지 사이즈/페이징 + Pageable pageable = PageRequest.of(0, Math.max(1, size)); + + // 4) 정렬 스위치별 슬라이스 조회 (qLike/qBlank 사용) + List rows = switch (effectiveSort) { + case LATEST -> sharedGameRepository.sliceLatest( + tagEmpty ? List.of(-1L) : tagIds, tagEmpty, + qLike, qBlank, + useCursor, lastSharedAt, lastId, + pageable + ); + case POPULAR -> sharedGameRepository.slicePopular( + tagEmpty ? List.of(-1L) : tagIds, tagEmpty, + qLike, qBlank, + useCursor, lastPlayCount, lastId, + pageable + ); + case TOP_SCORE -> sharedGameRepository.sliceTopScore( + tagEmpty ? List.of(-1L) : tagIds, tagEmpty, + qLike, qBlank, + useCursor, lastTopScore, lastId, + pageable + ); + }; + + // 5) DTO 매핑 (집계 일원화) + List items = rows.stream().map(g -> { + PublicSharedGameResponse dto = new PublicSharedGameResponse(); + dto.setSharedGameId(g.getUuid()); dto.setThumbnailUrl(g.getThumbnailUrl()); dto.setTitle(g.getTitle()); - dto.setTopScore(sharedGameScoreRepository.maxScoreBySharedGameId(g.getId())); dto.setSharedAt(g.getSharedAt()); + // 집계 dto.setTotalPlayCount(sharedGameScoreRepository.countBySharedGameId(g.getId())); dto.setLikeCount(sharedGameFavoriteRepository.countBySharedGameId(g.getId())); - if(userId != null) { - dto.setLiked(sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, g.getId())); - } + Long topScore = sharedGameScoreRepository.maxScoreBySharedGameId(g.getId()); + dto.setTopScore(topScore == null ? 0L : topScore); - List tags = gameTagRepository.findTagDtosBySharedGameId(g.getId()); - dto.setTags(tags); + // 좋아요 여부 + if (userId != null) { + boolean liked = sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, g.getId()); + dto.setLiked(liked); + } + // 태그 + dto.setTags(gameTagRepository.findTagDtosBySharedGameId(g.getId())); return dto; }).toList(); - Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getSharedGameId(); - return ResponseEntity.ok(new CursorPage<>(items, nextCursor, page.hasNext())); + // 6) 커서/hasNext + UUID nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getSharedGameId(); + boolean hasNext = rows.size() == Math.max(1, size); + + return ResponseEntity.ok(new CursorPage<>(items, nextCursor, hasNext)); } } From 8ab3ebc8915c67d5a5e40a7516cf4fff6d658439 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Tue, 16 Sep 2025 18:26:31 +0900 Subject: [PATCH 463/527] feat: solve cors error --- .../scriptopia/demo/config/SecurityConfig.java | 17 +++++++++++++++++ .../demo/controller/UserController.java | 1 + 2 files changed, 18 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 17071c0f..5f7cff64 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -20,6 +20,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; @@ -43,6 +45,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth //public 권한 @@ -71,4 +74,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ); return http.build(); } + + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(Arrays.asList("*")); // Authorization, Content-Type 등 허용 + config.setExposedHeaders(Arrays.asList("Authorization")); // 필요시 노출할 헤더 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index efafa61c..b330575c 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.history.HistoryPageResponse; +import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; From 1270beb2ca5c5974e98da0d0d1f33084e8b2b143 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 17 Sep 2025 20:16:19 +0900 Subject: [PATCH 464/527] feature/166-potion use --- .../demo/service/GameSessionService.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 89876075..3381bf2e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -42,6 +42,7 @@ public class GameSessionService { private final FastApiService fastApiService; private final ItemService itemService; private final InGameMapper inGameMapper; + private final ItemDefRepository itemDefRepository; public ResponseEntity getGameSession(Long userid) { @@ -1004,4 +1005,43 @@ private RewardInfoMongo handleReward(GameSessionMongo gameSessionMongo, RewardTy } return rewardInfo; } + + @Transactional + public void usePotion(Long userId, String ItemId) { + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); + + List items = gameSessionMongo.getInventory(); + + InventoryMongo targetItem = null; + for(InventoryMongo item : items) { + if(item.getItemDefId().equals(ItemId)) { + targetItem = item; + break; + } + } + + if(targetItem == null) { + throw new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND); + } + + com.scriptopia.demo.domain.ItemDef item = itemDefRepository.findById(Long.valueOf(ItemId)) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + if(item.getItemType() == ItemType.POTION) { + Integer life = playerInfo.getLife(); + Integer tmpLife = life + 1; + playerInfo.setLife(tmpLife); + + gameSessionMongoRepository.save(gameSessionMongo); + } + else { + throw new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND); + } + } } From b7ff01a3b4ab98b15f49536db65abdb370c37dd1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:13:30 +0900 Subject: [PATCH 465/527] =?UTF-8?q?refactor:=20change=20domainType=20Long?= =?UTF-8?q?=20to=20String=20mongoDB=5F=C3=A3ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java index 8518ba98..6c92c913 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/ShopInfoMongo.java @@ -9,5 +9,5 @@ @AllArgsConstructor @NoArgsConstructor public class ShopInfoMongo { - private List itemDefId; + private List itemDefId; } From 61d3c2cd9eb771e94527c8af3c45d63dcfee81f9 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:57:10 +0900 Subject: [PATCH 466/527] Refactor: update ChoiceResultType logic --- .../java/com/scriptopia/demo/domain/ChoiceResultType.java | 8 ++++---- .../demo/dto/gamesession/ingame/InGameShopTable.java | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index e4c8f4a7..84e455eb 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -7,10 +7,10 @@ @Getter public enum ChoiceResultType { - BATTLE(20), - CHOICE(40), - DONE(45), - SHOP(5); + BATTLE(2), // 20, 40, 45, 5 + CHOICE(3), + DONE(5), + SHOP(90); private final int nextEventType; diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java new file mode 100644 index 00000000..8abe36d1 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java @@ -0,0 +1,4 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +public class InGameShopTable { +} From e267dde9784ba409ee4203c6a423a5891e32b273 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:57:10 +0900 Subject: [PATCH 467/527] Feat: enhance InGameShopResponse DTO structure --- .../ingame/InGameShopResponse.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java index 9f3021d0..237c8f99 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopResponse.java @@ -1,4 +1,31 @@ package com.scriptopia.demo.dto.gamesession.ingame; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class InGameShopResponse { + private String sceneType; + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + private String background; + private String location; + private int progress; + private int stageSize; + + private InGamePlayerResponse playerInfo; // 외부 + private InGameNpcResponse npcInfo; // 외부 + private List inventory; // 외부 + + private List shopTable; // 외부 + + } From 7f725b029f6ff5693471677e45e651c8a27e4a34 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:57:10 +0900 Subject: [PATCH 468/527] Feat: add fields and logic to InGameShopTable --- .../gamesession/ingame/InGameShopTable.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java index 8abe36d1..d377125a 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java @@ -1,4 +1,41 @@ package com.scriptopia.demo.dto.gamesession.ingame; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder public class InGameShopTable { + + // 아이템 정의 정보 + private String name; + private String description; + private String itemPicSrc; + private String category; + private int baseStat; + private List itemEffects; + private int strength; + private int agility; + private int intelligence; + private int luck; + private String mainStat; + private String grade; + private int price; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ItemEffect { + private String itemEffectName; + private String itemEffectDescription; + private String grade; + } } From e103de16439ba1164e6a7f2421e006c2f8fa5750 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:57:11 +0900 Subject: [PATCH 469/527] Refactor: adjust InGameMapper mapping for shop system --- .../scriptopia/demo/mapper/InGameMapper.java | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java index 2871a7ae..adb02046 100644 --- a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -2,10 +2,7 @@ import com.scriptopia.demo.domain.mongo.*; -import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGameInventoryResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGameNpcResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGamePlayerResponse; +import com.scriptopia.demo.dto.gamesession.ingame.*; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.mongo.ItemDefMongoRepository; @@ -106,6 +103,37 @@ public List mapChoice(ChoiceInfoMongo choiceInfo) { .toList(); } + public List mapShopTable(List createShopItems) { + return createShopItems.stream() + .map(shopItem -> { + ItemDefMongo itemDef = itemDefMongoRepository.findById(shopItem) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + return InGameShopTable.builder() + // 아이템 정의 정보 + .name(itemDef.getName()) + .description(itemDef.getDescription()) + .itemPicSrc(itemDef.getItemPicSrc()) + .category(itemDef.getCategory().name()) + .baseStat(itemDef.getBaseStat()) + .itemEffects(itemDef.getItemEffect().stream() + .map(e -> InGameShopTable.ItemEffect.builder() + .itemEffectName(e.getItemEffectName()) + .itemEffectDescription(e.getItemEffectDescription()) + .grade(e.getEffectProbability().name()) + .build()) + .toList()) + .strength(itemDef.getStrength()) + .agility(itemDef.getAgility()) + .intelligence(itemDef.getIntelligence()) + .luck(itemDef.getLuck()) + .mainStat(itemDef.getMainStat().name()) + .grade(itemDef.getGrade().name()) + .price(itemDef.getPrice().intValue()) + .build(); + }) + .toList(); + } } From 427a7c3ae3e8ed5c126513caad98ba0493803d71 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 00:57:11 +0900 Subject: [PATCH 470/527] Feat: implement in-game shop handling logic in GameSessionService --- .../demo/service/GameSessionService.java | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 3381bf2e..2fb8760e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -3,6 +3,7 @@ import com.scriptopia.demo.dto.gamesession.ingame.InGameBattleResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; import com.scriptopia.demo.dto.gamesession.ingame.InGameDoneResponse; +import com.scriptopia.demo.dto.gamesession.ingame.InGameShopResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.mapper.InGameMapper; @@ -339,6 +340,20 @@ public Object getInGameDataDto(Long userId){ } else if (currentSceneType == SceneType.SHOP) { + return InGameShopResponse.builder() + .sceneType("SHOP") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(LocalDateTime.now()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) + .shopTable(inGameMapper.mapShopTable(gameSessionMongo.getShopInfo().getItemDefId())) + .build(); + + } else if (currentSceneType == SceneType.BATTLE) { BattleInfoMongo battleInfo = gameSessionMongo.getBattleInfo(); @@ -408,6 +423,9 @@ public GameSessionMongo gameProgress(Long userId) { case SceneType.DONE -> { gameToChoice(userId); } + case SceneType.SHOP -> { + gameToChoice(userId); + } default -> throw new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND); } @@ -807,7 +825,7 @@ public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) ChoiceResultType nextScene = choiceMongo.getResultType(); boolean isPass = GameBalanceUtil.isPass(probability); - RewardInfoMongo rewardInfo; + RewardInfoMongo rewardInfo = null; switch (nextScene) { case CHOICE -> { @@ -827,6 +845,33 @@ public GameSessionMongo gameChoiceSelect(Long userId, GameChoiceRequest request) gameSessionMongo = gameSessionMongoRepository.findById(gameId).get(); rewardInfo = handleReward(gameSessionMongo, rewardType, isPass); } + case SHOP -> { + gameSessionMongo = gameSessionMongoRepository.findById(gameId).get(); + List createdItems = gameSessionMongo.getCreatedItems(); + List createShopItems = new ArrayList<>(); + + ItemDefRequest itemDefRequest = ItemDefRequest.builder() + .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) + .location(gameSessionMongo.getLocation()) + .playerTrait(null) + .previousStory(gameSessionMongo.getBackground()) + .build(); + + for (int i=0; i<3; i+=1){ + String itemMongoId = itemService.createItemInGame(itemDefRequest); + createdItems.add(itemMongoId); + createShopItems.add(itemMongoId); + } + + ShopInfoMongo shopInfoMongo = ShopInfoMongo.builder() + .itemDefId(createShopItems) + .build(); + + gameSessionMongo.setCreatedItems(createdItems); + gameSessionMongo.setShopInfo(shopInfoMongo); + gameSessionMongo.setSceneType(SceneType.SHOP); + + } default -> throw new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND); } From afd98dff27def5e5174fa2075376484da21434da Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 01:43:06 +0900 Subject: [PATCH 471/527] Feat: create 409 ERROR --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index aaa4aa7f..3e429051 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -84,6 +84,7 @@ public enum ErrorCode { E_409_REFRESH_REUSE_DETECTED("E409003", "리프레시 토큰 재사용이 감지되었습니다.", HttpStatus.CONFLICT), E_409_PASSWORD_SAME_AS_OLD("E409004","기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.",HttpStatus.CONFLICT), E_409_ALREADY_CONFIRMED("E409005","이미 정산이 완료된 항목입니다.", HttpStatus.CONFLICT), + E_409_NOT_ENOUGH_MONEY("E4090056", "보유 금액이 부족합니다.", HttpStatus.CONFLICT), //412 Precondition Failed From 51a36a8c6808013d5f2401345f306694db77eada Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 01:54:26 +0900 Subject: [PATCH 472/527] feat: create item buy to shop logic --- .../controller/GameSessionController.java | 14 ++++++ .../demo/service/GameSessionService.java | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index ded8895b..cb759454 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -125,6 +125,20 @@ public ResponseEntity dropItem( return ResponseEntity.ok(response); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @DeleteMapping("/buyItem/{gameId}/{itemId}") + public ResponseEntity buyItem( + @PathVariable("gameId") String gameId, + @PathVariable("itemId") String itemId, + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + GameSessionMongo response = gameSessionService.gameBuyItem(userId, itemId); + + return ResponseEntity.ok(response); + } + /* * 게임 -> 기존 게임 조회 diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2fb8760e..0bf76985 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -961,6 +961,49 @@ public GameSessionMongo gameDropItem(Long userId, String itemId) { } + @Transactional + public GameSessionMongo gameBuyItem(Long userId, String itemId) { + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); + List inventory = gameSessionMongo.getInventory(); + ShopInfoMongo shopInfoMongo = gameSessionMongo.getShopInfo(); + List shopItems = shopInfoMongo.getItemDefId(); + + long playerGold = playerInfo.getGold(); + + + ItemDefMongo targetDef = itemDefMongoRepository.findById(itemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + long shopItemGold = targetDef.getPrice(); + + if(playerGold < shopItemGold){ + throw new CustomException(ErrorCode.E_409_NOT_ENOUGH_MONEY); + } + + + InventoryMongo buyItem = InventoryMongo.builder() + .itemDefId(itemId) + .acquiredAt(LocalDateTime.now()) + .equipped(false) + .source("item shop") + .build(); + + inventory.add(buyItem); + shopItems.remove(itemId); + shopInfoMongo.setItemDefId(shopItems); + + gameSessionMongo.setInventory(inventory); + gameSessionMongo.setShopInfo(shopInfoMongo); + + return gameSessionMongoRepository.save(gameSessionMongo); + } + private void addStats(PlayerInfoMongo player, ItemDefMongo item) { player.setStrength(player.getStrength() + safeStat(item.getStrength())); From 400b165241568af442413c30121d07c0f87662f4 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 01:58:37 +0900 Subject: [PATCH 473/527] Feat: add new 409 error if not sceneType --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 3 ++- .../java/com/scriptopia/demo/service/GameSessionService.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 3e429051..02967798 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -84,7 +84,8 @@ public enum ErrorCode { E_409_REFRESH_REUSE_DETECTED("E409003", "리프레시 토큰 재사용이 감지되었습니다.", HttpStatus.CONFLICT), E_409_PASSWORD_SAME_AS_OLD("E409004","기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.",HttpStatus.CONFLICT), E_409_ALREADY_CONFIRMED("E409005","이미 정산이 완료된 항목입니다.", HttpStatus.CONFLICT), - E_409_NOT_ENOUGH_MONEY("E4090056", "보유 금액이 부족합니다.", HttpStatus.CONFLICT), + E_409_NOT_ENOUGH_MONEY("E409006", "보유 금액이 부족합니다.", HttpStatus.CONFLICT), + E_409_NOT_THIS_SCENE("E409007", "적합하지 않은 곳입니다.", HttpStatus.CONFLICT), //412 Precondition Failed diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 0bf76985..2ca7fb27 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -969,6 +969,10 @@ public GameSessionMongo gameBuyItem(Long userId, String itemId) { GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + if (gameSessionMongo.getSceneType() != SceneType.SHOP) { + throw new CustomException(ErrorCode.E_409_NOT_THIS_SCENE); + } + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); List inventory = gameSessionMongo.getInventory(); ShopInfoMongo shopInfoMongo = gameSessionMongo.getShopInfo(); From 5a37a64d0c223bdf50001d39801164a81b4d6552 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 02:27:20 +0900 Subject: [PATCH 474/527] refactor: add buy/sell item endpoints to GameSessionController --- .../demo/controller/GameSessionController.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index cb759454..7afc8368 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -139,6 +139,19 @@ public ResponseEntity buyItem( return ResponseEntity.ok(response); } + @PreAuthorize("hasAnyAuthority('USER','ADMIN')") + @DeleteMapping("/sellItem/{gameId}/{itemId}") + public ResponseEntity sellItem( + @PathVariable("gameId") String gameId, + @PathVariable("itemId") String itemId, + Authentication authentication) throws JsonProcessingException { + + Long userId = Long.valueOf(authentication.getName()); + + GameSessionMongo response = gameSessionService.gameSellItem(userId, itemId); + + return ResponseEntity.ok(response); + } /* * 게임 -> 기존 게임 조회 From e68f6e1e86be067750dce4411e036213fcf2c25a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 02:27:23 +0900 Subject: [PATCH 475/527] refactor: add NOT_ENOUGH_MONEY and DONT_SELL_EQUIPPED_ITEM error codes --- src/main/java/com/scriptopia/demo/exception/ErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java index 02967798..792f854b 100644 --- a/src/main/java/com/scriptopia/demo/exception/ErrorCode.java +++ b/src/main/java/com/scriptopia/demo/exception/ErrorCode.java @@ -86,6 +86,8 @@ public enum ErrorCode { E_409_ALREADY_CONFIRMED("E409005","이미 정산이 완료된 항목입니다.", HttpStatus.CONFLICT), E_409_NOT_ENOUGH_MONEY("E409006", "보유 금액이 부족합니다.", HttpStatus.CONFLICT), E_409_NOT_THIS_SCENE("E409007", "적합하지 않은 곳입니다.", HttpStatus.CONFLICT), + E_409_DONT_SELL_EQUIPPED_ITEM("E409008", "착용중인 아이템은 팔 수 없습니다.", HttpStatus.CONFLICT), + E_404_ITEM_NOT_IN_SHOP("E409009", "아이템이 이미 팔렸습니다.", HttpStatus.CONFLICT), //412 Precondition Failed From 2939cdfc2a80c192c3a91e12318a7232675a70a6 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 02:27:26 +0900 Subject: [PATCH 476/527] refactor: implement gameBuyItem and gameSellItem with validation and inventory update --- .../demo/service/GameSessionService.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 2ca7fb27..8c2db901 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -980,6 +980,9 @@ public GameSessionMongo gameBuyItem(Long userId, String itemId) { long playerGold = playerInfo.getGold(); + if (!shopItems.contains(itemId)) { + throw new CustomException(ErrorCode.E_404_ITEM_NOT_IN_SHOP); + } ItemDefMongo targetDef = itemDefMongoRepository.findById(itemId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); @@ -990,6 +993,12 @@ public GameSessionMongo gameBuyItem(Long userId, String itemId) { throw new CustomException(ErrorCode.E_409_NOT_ENOUGH_MONEY); } + playerGold = playerGold - shopItemGold; + playerInfo.setGold(playerGold); + + + shopItems.remove(itemId); + shopInfoMongo.setItemDefId(shopItems); InventoryMongo buyItem = InventoryMongo.builder() .itemDefId(itemId) @@ -999,11 +1008,50 @@ public GameSessionMongo gameBuyItem(Long userId, String itemId) { .build(); inventory.add(buyItem); - shopItems.remove(itemId); - shopInfoMongo.setItemDefId(shopItems); + gameSessionMongo.setInventory(inventory); gameSessionMongo.setShopInfo(shopInfoMongo); + gameSessionMongo.setPlayerInfo(playerInfo); + + return gameSessionMongoRepository.save(gameSessionMongo); + } + + @Transactional + public GameSessionMongo gameSellItem(Long userId, String itemId) { + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + if (gameSessionMongo.getSceneType() != SceneType.SHOP) { + throw new CustomException(ErrorCode.E_409_NOT_THIS_SCENE); + } + + PlayerInfoMongo playerInfo = gameSessionMongo.getPlayerInfo(); + List inventory = gameSessionMongo.getInventory(); + long playerGold = playerInfo.getGold(); + + InventoryMongo targetInventory = inventory.stream() + .filter(inv -> inv.getItemDefId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + if (targetInventory.isEquipped()) { + throw new CustomException(ErrorCode.E_409_DONT_SELL_EQUIPPED_ITEM); + } + + ItemDefMongo targetDef = itemDefMongoRepository.findById(itemId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND)); + + playerGold = playerGold + targetDef.getPrice(); + + inventory.remove(targetInventory); + playerInfo.setGold(playerGold); + + gameSessionMongo.setInventory(inventory); + gameSessionMongo.setPlayerInfo(playerInfo); return gameSessionMongoRepository.save(gameSessionMongo); } From 1ebd9e25f6dfb9f6e61ebd14a0c836307d297180 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 02:32:50 +0900 Subject: [PATCH 477/527] refactor: add shop domain itemDefMongo ID --- .../scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java | 1 + src/main/java/com/scriptopia/demo/mapper/InGameMapper.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java index d377125a..9eb12842 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameShopTable.java @@ -15,6 +15,7 @@ public class InGameShopTable { // 아이템 정의 정보 + private String shopItemId; private String name; private String description; private String itemPicSrc; diff --git a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java index adb02046..3a71ba9a 100644 --- a/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java +++ b/src/main/java/com/scriptopia/demo/mapper/InGameMapper.java @@ -112,6 +112,7 @@ public List mapShopTable(List createShopItems) { return InGameShopTable.builder() // 아이템 정의 정보 + .shopItemId(itemDef.getId()) .name(itemDef.getName()) .description(itemDef.getDescription()) .itemPicSrc(itemDef.getItemPicSrc()) From 6f1b2e88db631498f607544473e20046a8018984 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 18 Sep 2025 02:49:14 +0900 Subject: [PATCH 478/527] refactor: change endpint and before merge --- .../com/scriptopia/demo/controller/GameSessionController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 7afc8368..482c0453 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -126,7 +126,7 @@ public ResponseEntity dropItem( } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @DeleteMapping("/buyItem/{gameId}/{itemId}") + @PostMapping("/{gameId}/items/purchase/{itemId}") public ResponseEntity buyItem( @PathVariable("gameId") String gameId, @PathVariable("itemId") String itemId, @@ -140,7 +140,7 @@ public ResponseEntity buyItem( } @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @DeleteMapping("/sellItem/{gameId}/{itemId}") + @PostMapping("/{gameId}/items/sell/{itemId}") public ResponseEntity sellItem( @PathVariable("gameId") String gameId, @PathVariable("itemId") String itemId, From de8dba4ab792123cca235fcaa189b6ee6585d6e3 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Sat, 20 Sep 2025 16:33:27 +0900 Subject: [PATCH 479/527] feat: update entity attribute name --- src/main/java/com/scriptopia/demo/domain/ItemDef.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ItemDef.java b/src/main/java/com/scriptopia/demo/domain/ItemDef.java index e3d935c0..34e94b6c 100644 --- a/src/main/java/com/scriptopia/demo/domain/ItemDef.java +++ b/src/main/java/com/scriptopia/demo/domain/ItemDef.java @@ -20,6 +20,9 @@ public class ItemDef { @ManyToOne(fetch = FetchType.LAZY) private ItemGradeDef itemGradeDef; + @OneToMany(mappedBy = "itemDef", cascade = CascadeType.ALL, orphanRemoval = true) + private List itemEffects = new ArrayList<>(); + private String name; @Column(columnDefinition = "TEXT") @@ -43,7 +46,6 @@ public class ItemDef { private Long price; - @OneToMany(mappedBy = "itemDef", cascade = CascadeType.ALL, orphanRemoval = true) - private List itemEffects = new ArrayList<>(); + } \ No newline at end of file From 5ccbdc1143d7c8ff8eea05bcfe2586177efe50c2 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 20 Sep 2025 18:04:14 +0900 Subject: [PATCH 480/527] feat: create localDataSeeder initializer --- .../demo/config/LocalDataSeeder.java | 661 ++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java diff --git a/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java b/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java new file mode 100644 index 00000000..ed2075bd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java @@ -0,0 +1,661 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.domain.*; +import com.scriptopia.demo.repository.*; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +/** + * 로컬 개발용 초기 데이터 시드 + * - Pia 캐시템 3개 (100~300) + * - 로컬 계정 유저 2명(userA, userB) + UserSetting 초기값 + PIA 2000 + * - 각 유저: 랜덤 아이템 20개 보유, 그 중 15개 경매장에 등록 + * - 각 유저: 캐릭터 이미지 5개 + * - 각 유저: 히스토리 4개 (그중 2개는 공유, 태그 매핑, 평점/즐겨찾기 약간) + * - 태그 10개 생성 + * - A↔B 상호 거래 5건 완료(정산 Settlement 기록 포함) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalDataSeeder implements ApplicationRunner { + + private final ItemDefRepository itemDefRepository; + private final ItemGradeDefRepository itemGradeDefRepository; + private final EffectGradeDefRepository effectGradeDefRepository; + + private final UserRepository userRepository; + private final LocalAccountRepository localAccountRepository; + private final UserSettingRepository userSettingRepository; + + private final PiaItemRepository piaItemRepository; + private final UserItemRepository userItemRepository; + private final AuctionRepository auctionRepository; + + private final TagDefRepository tagDefRepository; + private final SharedGameRepository sharedGameRepository; + private final HistoryRepository historyRepository; + private final GameTagRepository gameTagRepository; + private final SharedGameScoreRepository sharedGameScoreRepository; + private final SharedGameFavoriteRepository sharedGameFavoriteRepository; + private final UserCharacterImgRepository userCharacterImgRepository; + + private final SettlementRepository settlementRepository; + + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public void run(ApplicationArguments args) { + log.info("=== LocalDataSeeder: start ==="); + + // 0) 이미 데이터가 있으면 중복 시드 방지 + if (userRepository.count() > 1) { + log.info("Users already exist. Skip seeding."); + return; + } + + // 1) 태그 10개 + List tags = ensureTags(); + + // 2) Pia 캐시 아이템 3개 + ensurePiaItems(); + + // 3) 정의 테이블(등급, 효과 등급) + Map gradeMap = ensureItemGradeDefs(); + Map effectGradeMap = ensureEffectGradeDefs(); + + // 4) 아이템 카탈로그 + 아이템 효과 + ensureItemCatalog(gradeMap, effectGradeMap); + + // 5) 유저 2명 + 로컬계정 + 설정 + pia=2000 + User userA = createUserWithLocal("userA", "userA@example.com", "userA!234"); + User userB = createUserWithLocal("userB", "userB@example.com", "userB!234"); + setUserSettingDefaults(userA); + setUserSettingDefaults(userB); + setPia(userA, 2000); + setPia(userB, 2000); + + // 6) 캐릭터 이미지 5장씩 + addCharacterImages(userA, 5); + addCharacterImages(userB, 5); + + // 7) 유저 인벤토리 20개(그중 15개 경매 등록) + List invA = createRandomInventory(userA, 20); + List invB = createRandomInventory(userB, 20); + List aucA = listFirstNOnAuction(invA, 15); // A가 올린 경매 + List aucB = listFirstNOnAuction(invB, 15); // B가 올린 경매 + + // 8) 히스토리 4개(그중 2개 공유+태그/평점/즐겨찾기) + createHistoriesAndShared(userA, tags); + createHistoriesAndShared(userB, tags); + + // 9) A↔B 상호 거래 5건 완료(정산 기록 포함) + createTradeLogs(userA, userB, aucA, aucB, 5); + + log.info("[seed] done"); + } + + /* ===================================================================================== + 태그 / PIA (정의 테이블) + ===================================================================================== */ + + private List ensureTags() { + if (tagDefRepository.count() >= 10) { + return tagDefRepository.findAll(); + } + List names = List.of("로맨스","판타지","추리","던전","해적","학교물","느와르","우주","요리","타임리프"); + List list = new ArrayList<>(); + for (String n : names) { + TagDef t = new TagDef(); + t.setTagName(n); + list.add(t); + } + return tagDefRepository.saveAll(list); + } + + private void ensurePiaItems() { + if (piaItemRepository.count() >= 3) return; + PiaItem p1 = new PiaItem(); p1.setName("아이템 모루"); p1.setPrice(200L); p1.setDescription("장비 강화용"); + PiaItem p2 = new PiaItem(); p2.setName("연마석"); p2.setPrice(randL(120, 180)); p2.setDescription("날카로움 보정"); + PiaItem p3 = new PiaItem(); p3.setName("수선 키트"); p3.setPrice(randL(100, 160)); p3.setDescription("내구도 회복"); + piaItemRepository.saveAll(List.of(p1,p2,p3)); + } + + /* ===================================================================================== + ItemGradeDef / EffectGradeDef (정의 테이블) + ===================================================================================== */ + + private Map ensureItemGradeDefs() { + List existing = itemGradeDefRepository.findAll(); + Map byEnum = existing.stream() + .collect(Collectors.toMap(ItemGradeDef::getGrade, x -> x)); + + List toSave = new ArrayList<>(); + for (Grade g : Grade.values()) { + if (byEnum.containsKey(g)) continue; + ItemGradeDef def = new ItemGradeDef(); + def.setGrade(g); + def.setWeight(g.getDropRate()); + long base = Math.round((g.getAttackPower() + g.getDefensePower()) / 2.0); + def.setPrice(base * 10L); + toSave.add(def); + } + if (!toSave.isEmpty()) { + itemGradeDefRepository.saveAll(toSave); + toSave.forEach(d -> byEnum.put(d.getGrade(), d)); + } + return byEnum; + } + + private Map ensureEffectGradeDefs() { + List existing = effectGradeDefRepository.findAll(); + Map byEnum = existing.stream() + .collect(Collectors.toMap(EffectGradeDef::getEffectProbability, x -> x)); + + List toSave = new ArrayList<>(); + for (EffectProbability p : EffectProbability.values()) { + if (byEnum.containsKey(p)) continue; + EffectGradeDef def = new EffectGradeDef(); + def.setEffectProbability(p); + def.setWeight( + switch (p) { + case COMMON -> 50d; case UNCOMMON -> 30d; case RARE -> 12d; case EPIC -> 6d; case LEGENDARY -> 2d; + } + ); + def.setPrice( + switch (p) { + case COMMON -> 20L; case UNCOMMON -> 60L; case RARE -> 150L; case EPIC -> 400L; case LEGENDARY -> 1000L; + } + ); + toSave.add(def); + } + if (!toSave.isEmpty()) { + effectGradeDefRepository.saveAll(toSave); + toSave.forEach(d -> byEnum.put(d.getEffectProbability(), d)); + } + return byEnum; + } + + /* ===================================================================================== + ItemDef + ItemEffect (연관관계 제대로 연결) — 컨셉 기반 + ===================================================================================== */ + + private void ensureItemCatalog(Map gradeMap, + Map effectGradeMap) { + if (itemDefRepository.count() >= 32) return; // 컨셉 다양화라 넉넉히 + + List toSave = new ArrayList<>(); + + for (ItemType type : ItemType.values()) { + for (Grade g : Grade.values()) { + int perCombo = 2; // 컨셉 섞어 2개씩 생성 + for (int i = 0; i < perCombo; i++) { + String concept = pickConcept(); + + ItemDef d = new ItemDef(); + d.setItemGradeDef(gradeMap.get(g)); + d.setItemType(type); + d.setMainStat(Stat.getRandomMainStat()); + + // 이름/설명/이미지 + d.setName(buildItemName(concept, type, g, d.getMainStat())); + d.setDescription(buildItemDescription(concept, type, g, d.getMainStat())); + d.setPicSrc(picsumWithSeed(300, 400, concept + "-" + type + "-" + g)); + + // 능력치: 등급 기반 + 컨셉/타입 보정 + int base = Grade.getRandomBaseStat(type, g); + int conceptBonus = conceptBaseBonus(concept, type, d.getMainStat()); + d.setBaseStat(Math.max(1, base + conceptBonus)); + d.setStrength(rand(0, 10)); + d.setAgility(rand(0, 10)); + d.setIntelligence(rand(0, 10)); + d.setLuck(rand(0, 10)); + + d.setCreatedAt(LocalDateTime.now()); + + long basePrice = Optional.ofNullable(d.getItemGradeDef().getPrice()).orElse(100L); + long optSum = nz(d.getStrength()) + nz(d.getAgility()) + nz(d.getIntelligence()) + nz(d.getLuck()); + long conceptPremium = conceptPricePremium(concept, type, g); + d.setPrice(basePrice + optSum * 5 + conceptPremium); + + // 효과 0~3개 + int effectCnt = rand(0, 3); + for (int k = 0; k < effectCnt; k++) { + EffectProbability picked = EffectProbability.getRandomEffectGradeByWeaponGrade(g); + if (picked == null) continue; + EffectGradeDef egd = effectGradeMap.get(picked); + if (egd == null) continue; + + ItemEffect ef = new ItemEffect(); + ef.setItemDef(d); + ef.setEffectGradeDef(egd); + ef.setEffectName(randomEffectName(concept, d.getItemType(), d.getMainStat(), picked)); + ef.setEffectDescription("[" + picked.name() + "] " + conceptTagline(concept)); + d.getItemEffects().add(ef); + } + + toSave.add(d); + } + } + } + + itemDefRepository.saveAll(toSave); + } + + /* ===================================================================================== + 유저/설정/PIA/캐릭터이미지/인벤토리/경매/히스토리/공유/정산 + ===================================================================================== */ + + private User createUserWithLocal(String nickname, String email, String rawPw) { + User u = new User(); + u.setNickname(nickname); + u.setRole(Role.USER); + u.setLoginType(LoginType.LOCAL); + u.setCreatedAt(LocalDateTime.now()); + u.setLastLoginAt(LocalDateTime.now()); + u.setProfileImgUrl(picsum(256,256)); + userRepository.save(u); + + LocalAccount acc = new LocalAccount(); + acc.setUser(u); + acc.setEmail(email); + acc.setPassword(passwordEncoder.encode(rawPw)); + acc.setStatus(UserStatus.VERIFIED); + acc.setUpdatedAt(LocalDateTime.now()); + localAccountRepository.save(acc); + + return u; + } + + private void setUserSettingDefaults(User user) { + UserSetting s = new UserSetting(); + s.setUser(user); + s.setTheme(Theme.DARK); + s.setFontType(FontType.PretendardVariable); + s.setFontSize(16); + s.setLineHeight(1); + s.setWordSpacing(1); + s.setUpdatedAt(LocalDateTime.now()); + userSettingRepository.save(s); + } + + private void setPia(User user, long amount) { + user.setPia(amount); + userRepository.save(user); + } + + private void addCharacterImages(User user, int count) { + List imgs = new ArrayList<>(); + for (int i = 0; i < count; i++) { + UserCharacterImg img = new UserCharacterImg(); + img.setUser(user); + img.setImgUrl("https://picsum.photos/seed/" + user.getId() + "-" + i + "/256/256"); + imgs.add(img); + } + userCharacterImgRepository.saveAll(imgs); + } + + private List createRandomInventory(User owner, int count) { + List catalog = itemDefRepository.findAll(); + if (catalog.isEmpty()) throw new IllegalStateException("Item catalog empty"); + List items = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ItemDef base = catalog.get(rand(0, catalog.size()-1)); + UserItem ui = new UserItem(); + ui.setUser(owner); + ui.setItemDef(base); + ui.setTradeStatus(TradeStatus.OWNED); + ui.setRemainingUses(rand(0,10)); + items.add(ui); + } + return userItemRepository.saveAll(items); + } + + private List listFirstNOnAuction(List items, int n) { + List created = new ArrayList<>(); + items.stream().limit(n).forEach(ui -> { + ui.setTradeStatus(TradeStatus.LISTED); + userItemRepository.save(ui); + + Auction a = new Auction(); + a.setUserItem(ui); + a.setPrice(randL(200, 1800)); + a.setCreatedAt(LocalDateTime.now().minusHours(rand(0,48))); + // 미완료 상태(tradedAt=null) + created.add(auctionRepository.save(a)); + }); + return created; + } + + private void createHistoriesAndShared(User user, List tags) { + // 히스토리 4개 + List histories = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + History h = new History(); + h.setUser(user); + h.setUuid(UUID.randomUUID()); + h.setThumbnailUrl(picsum(300, 400)); + h.setTitle(user.getNickname() + "의 모험 #" + (i+1)); + h.setScore((long) rand(60,98)); + h.setCreatedAt(LocalDateTime.now().minusDays(rand(0,10))); + h.setIsShared(false); + histories.add(h); + } + historyRepository.saveAll(histories); + + // 그중 2개 공유 + for (int i = 0; i < 2; i++) { + History h = histories.get(i); + h.setIsShared(true); + historyRepository.save(h); + + SharedGame sg = new SharedGame(); + sg.setUser(user); + sg.setUuid(h.getUuid()); + sg.setThumbnailUrl(h.getThumbnailUrl()); + sg.setTitle(h.getTitle()); + sg.setWorldView("임시 세계관"); + sg.setBackgroundStory("임시 배경"); + sg.setSharedAt(LocalDateTime.now()); + sharedGameRepository.save(sg); + + // 태그 2~5개 + Collections.shuffle(tags); + int tagCount = rand(2,5); + for (int k=0; k aucA, List aucB, + int totalTrades) { + Collections.shuffle(aucA); + Collections.shuffle(aucB); + + int byB = Math.min(totalTrades / 2, aucA.size()); // B가 A 물건 구매 + int byA = Math.min(totalTrades - byB, aucB.size()); // A가 B 물건 구매 + + for (int i = 0; i < byB; i++) finalizeTrade(userA, userB, aucA.get(i)); + for (int i = 0; i < byA; i++) finalizeTrade(userB, userA, aucB.get(i)); + } + + /** 단일 거래 완료 처리 */ + private void finalizeTrade(User seller, User buyer, Auction auction) { + if (auction.getTradedAt() != null) return; // 이미 완료된 거래 + + UserItem ui = auction.getUserItem(); + long price = Optional.ofNullable(auction.getPrice()).orElse(0L); + + // 구매자 잔액 확인(부족하면 스킵) + if (buyer.getPia() == null || buyer.getPia() < price) return; + + // 정산 + buyer.setPia(buyer.getPia() - price); + seller.setPia(Optional.ofNullable(seller.getPia()).orElse(0L) + price); + userRepository.saveAll(List.of(buyer, seller)); + + // 소유권 이전 + 상태 변경 + ui.setUser(buyer); + ui.setTradeStatus(TradeStatus.OWNED); + userItemRepository.save(ui); + + // 경매 완료 시간 기록 + auction.setTradedAt(LocalDateTime.now()); + auctionRepository.save(auction); + + ItemDef itemDef = ui.getItemDef(); + + // Settlement: 판매자(SELL) + Settlement sellSettle = new Settlement(); + sellSettle.setUser(seller); + sellSettle.setItemDef(itemDef); + sellSettle.setTradeType(TradeType.SELL); // enum: SELL/BUY 필요 + sellSettle.setPrice(price); + sellSettle.setCreatedAt(LocalDateTime.now()); + sellSettle.setSettledAt(LocalDateTime.now()); + settlementRepository.save(sellSettle); + + // Settlement: 구매자(BUY) + Settlement buySettle = new Settlement(); + buySettle.setUser(buyer); + buySettle.setItemDef(itemDef); + buySettle.setTradeType(TradeType.BUY); + buySettle.setPrice(price); + buySettle.setCreatedAt(LocalDateTime.now()); + buySettle.setSettledAt(LocalDateTime.now()); + settlementRepository.save(buySettle); + } + + /* ===================================================================================== + 유틸 + ===================================================================================== */ + + // ======================= 컨셉 사전 ======================= + private static final List CONCEPTS = List.of( + "스팀펑크", "사이버네온", "암흑 판타지", "동양 무협", "우주 SF", + "요리 배틀", "해적 시대", "포스트 아포칼립스", "중세 마법학원", "바이오펑크" + ); + + private static String pickConcept() { return CONCEPTS.get(rand(0, CONCEPTS.size()-1)); } + + private static String conceptTagline(String concept) { + return switch (concept) { + case "스팀펑크" -> "황동과 기어의 울림"; + case "사이버네온" -> "빛번짐 속 프로토콜"; + case "암흑 판타지" -> "어둠이 가르는 맹세"; + case "동양 무협" -> "내공과 검기"; + case "우주 SF" -> "진공 너머 특이점"; + case "요리 배틀" -> "칼끝에서 피어나는 풍미"; + case "해적 시대" -> "검과 파도, 검은 깃발"; + case "포스트 아포칼립스" -> "폐허 속 생존 기술"; + case "중세 마법학원" -> "룬과 마력회로"; + case "바이오펑크" -> "세포 공학적 변이"; + default -> "특별한 콘셉트"; + }; + } + + private static String buildItemName(String concept, ItemType type, Grade g, Stat main) { + String gradePrefix = switch (g) { + case LEGENDARY -> "전설의 "; + case EPIC -> "에픽 "; + case RARE -> "희귀 "; + case UNCOMMON -> "고급 "; + case COMMON -> ""; + }; + + String noun = switch (type) { + case WEAPON -> pickOne(List.of("기어블레이드", "광자검", "혈문도", "룬스태프", "해적커틀러스", "강철장도", "마나활", "열압권총")); + case ARMOR -> pickOne(List.of("기계갑옷", "네온코트", "어둠의 흉갑", "비단갑", "우주복", "셰프앞치마", "해골흉갑", "겐지로브")); + case ARTIFACT -> pickOne(List.of("증기코어", "신경임플란트", "어비스 보주", "학원 배지", "항성 파편", "미각 토템")); + case POTION -> pickOne(List.of("촉매 엘릭서", "신경강화제", "밤피의 혈약", "기혈단", "중력완화제", "풍미증폭 소스")); + }; + + String conceptAdj = switch (concept) { + case "스팀펑크" -> pickOne(List.of("황동제", "증기식", "기어식")); + case "사이버네온" -> pickOne(List.of("네온-튜닝", "양자", "신경망")); + case "암흑 판타지" -> pickOne(List.of("그림자", "혈문", "망령")); + case "동양 무협" -> pickOne(List.of("벽력", "천검", "비연")); + case "우주 SF" -> pickOne(List.of("중성자", "쿼크", "항성")); + case "요리 배틀" -> pickOne(List.of("주방장", "풍미", "향신")); + case "해적 시대" -> pickOne(List.of("검은깃발", "해골", "산호")); + case "포스트 아포칼립스" -> pickOne(List.of("폐허산", "방사", "고철")); + case "중세 마법학원" -> pickOne(List.of("룬각", "비전", "원소")); + case "바이오펑크" -> pickOne(List.of("유전자", "세포", "점액질")); + default -> ""; + }; + + String mainHint = switch (main) { + case STRENGTH -> "괴력"; + case AGILITY -> "신속"; + case INTELLIGENCE -> "지성"; + case LUCK -> "포츈"; + }; + + return gradePrefix + conceptAdj + " " + noun + " • " + mainHint; + } + + private static String buildItemDescription(String concept, ItemType type, Grade g, Stat main) { + String line1 = "세계관: " + concept + " | 주 스탯: " + main; + String line2 = switch (type) { + case WEAPON -> "공격기반 무기. " + conceptTagline(concept); + case ARMOR -> "방어/생존 특화. " + conceptTagline(concept); + case ARTIFACT -> "특수 패시브/효과 중심. " + conceptTagline(concept); + case POTION -> "일시적 버프/회복. " + conceptTagline(concept); + }; + String line3 = switch (g) { + case LEGENDARY -> "희귀한 제작법이 전해진다."; + case EPIC -> "베테랑 장인들의 정수가 담겼다."; + case RARE -> "전투에서 검증된 성능."; + case UNCOMMON -> "균형 잡힌 성능."; + case COMMON -> "보급형 표준 모델."; + }; + return line1 + "\n" + line2 + "\n" + line3; + } + + private static int conceptBaseBonus(String concept, ItemType type, Stat main) { + int bias = 0; + if (concept.equals("스팀펑크") && type == ItemType.WEAPON) bias += 6; + if (concept.equals("사이버네온") && (main == Stat.AGILITY || main == Stat.INTELLIGENCE)) bias += 8; + if (concept.equals("암흑 판타지") && type == ItemType.ARMOR) bias += 5; + if (concept.equals("동양 무협") && (type == ItemType.WEAPON || main == Stat.STRENGTH)) bias += 7; + if (concept.equals("우주 SF") && type == ItemType.ARTIFACT) bias += 9; + if (concept.equals("요리 배틀") && type == ItemType.POTION) bias += 10; + if (concept.equals("해적 시대") && main == Stat.LUCK) bias += 6; + if (concept.equals("포스트 아포칼립스") && type == ItemType.ARMOR) bias += 4; + if (concept.equals("중세 마법학원") && main == Stat.INTELLIGENCE) bias += 8; + if (concept.equals("바이오펑크") && type == ItemType.ARTIFACT) bias += 6; + + bias += rand(-3, 3); + return bias; + } + + private static long conceptPricePremium(String concept, ItemType type, Grade g) { + int tier = switch (g) { + case LEGENDARY -> 5; case EPIC -> 4; case RARE -> 3; case UNCOMMON -> 2; case COMMON -> 1; + }; + int base = switch (type) { + case WEAPON -> 60; case ARMOR -> 45; case ARTIFACT -> 80; case POTION -> 25; + }; + int conceptFactor = switch (concept) { + case "우주 SF", "바이오펑크" -> 50; + case "사이버네온", "중세 마법학원" -> 40; + case "스팀펑크", "암흑 판타지" -> 35; + case "해적 시대" -> 30; + case "포스트 아포칼립스" -> 28; + case "동양 무협" -> 32; + case "요리 배틀" -> 20; + default -> 25; + }; + return (long) ((base + conceptFactor) * tier); + } + + private static String pickOne(List list) { return list.get(rand(0, list.size()-1)); } + private static String picsumWithSeed(int w, int h, String seed) { + return "https://picsum.photos/seed/" + seed.replaceAll("\\s+","_") + "/" + w + "/" + h; + } + + private static int rand(int min, int max) { + return ThreadLocalRandom.current().nextInt(min, max + 1); + } + private static long randL(int min, int max) { return rand(min, max); } + private static boolean randBool() { return ThreadLocalRandom.current().nextBoolean(); } + private static String token() { String a="ABCDEFGHJKLMNPQRSTUVWXYZ"; return ""+a.charAt(rand(0,a.length()-1))+rand(0,999); } + private static String picsum(int w, int h) { return "https://picsum.photos/" + w + "/" + h + "?random=" + UUID.randomUUID(); } + private static String lorem(int words) { + String base="An ancient relic hums with latent power as shadows gather over Scriptopia "; + String[] arr=base.split("\\s+"); StringBuilder sb=new StringBuilder(); + for(int i=0;i pool = new ArrayList<>(); + + // 컨셉 시그니처 + switch (concept) { + case "스팀펑크" -> pool.addAll(List.of("증기 분출", "기어 과부하", "압력 누적", "피스톤 충격")); + case "사이버네온" -> pool.addAll(List.of("신경 가속", "패킷 주입", "광자 잔상", "방화벽 관통")); + case "암흑 판타지" -> pool.addAll(List.of("그림자 맹세", "피의 저주", "망령 서약", "어비스의 응시")); + case "동양 무협" -> pool.addAll(List.of("검기 방출", "경공술", "내공 폭진", "기혈 순환")); + case "우주 SF" -> pool.addAll(List.of("중력 왜곡", "차원 흔들림", "항성열 방사", "양자 난류")); + case "요리 배틀" -> pool.addAll(List.of("풍미 증폭", "감칠맛 폭격", "식감 강화", "향신료 분사")); + case "해적 시대" -> pool.addAll(List.of("대포 사격", "돛바람 가속", "해안 급습", "검은 파도")); + case "포스트 아포칼립스" -> pool.addAll(List.of("방사 저항", "고철 방패", "연료 분사", "황폐의 굴레")); + case "중세 마법학원" -> pool.addAll(List.of("룬 각성", "비전 증폭", "소환 공명", "원소 가호")); + case "바이오펑크" -> pool.addAll(List.of("세포 재생", "신경 동조", "점액질 보호막", "유전자 각성")); + } + + // 타입 기반 추가 + switch (type) { + case WEAPON -> pool.addAll(List.of("치명타 확률", "관통", "연격", "출혈", "화염 부여")); + case ARMOR -> pool.addAll(List.of("피해 감소", "빙결 저항", "화염 저항", "보호막", "재생")); + case ARTIFACT -> pool.addAll(List.of("축복", "가속", "행운의 일격", "마력 증폭", "집중")); + case POTION -> pool.addAll(List.of("즉시 회복", "해제", "광폭화", "은신", "정화")); + } + + // 메인 스탯 보정 + switch (mainStat) { + case STRENGTH -> pool.addAll(List.of("분쇄", "분노", "괴력")); + case AGILITY -> pool.addAll(List.of("신속", "회피", "날렵함")); + case INTELLIGENCE -> pool.addAll(List.of("지성 증폭", "집중", "분석")); + case LUCK -> pool.addAll(List.of("포춘", "크리 운빨", "은총")); + } + + // 등급 접두사 + String prefix = switch (grade) { + case LEGENDARY -> "전설의 "; + case EPIC -> "에픽 "; + case RARE -> "희귀 "; + case UNCOMMON -> "고급 "; + case COMMON -> ""; + }; + + if (pool.isEmpty()) pool = List.of("특수 효과"); + return prefix + pool.get(rand(0, pool.size() - 1)); + } +} From 76f4cb5821e75c4b3ebc92b7863a888903b82bae Mon Sep 17 00:00:00 2001 From: juns0720 Date: Sat, 20 Sep 2025 21:52:59 +0900 Subject: [PATCH 481/527] feat: update yml --- src/main/resources/application.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7e14b2fe..08409047 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,8 +57,6 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} scope: name email profile_image - - auth: jwt: issuer: scriptopia From b8ba46028c76cfd289e9d2b4c7e47723042d2069 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 27 Sep 2025 18:21:47 +0900 Subject: [PATCH 482/527] refactor: improve game session service, choice result type, game balance util, and index.html UI\ --- .../demo/domain/ChoiceResultType.java | 8 +- .../demo/service/GameSessionService.java | 4 +- .../demo/utils/GameBalanceUtil.java | 13 +- src/main/resources/templates/index.html | 451 ++++++++++++++++-- 4 files changed, 415 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index 84e455eb..18f0f46e 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -7,10 +7,10 @@ @Getter public enum ChoiceResultType { - BATTLE(2), // 20, 40, 45, 5 - CHOICE(3), - DONE(5), - SHOP(90); + BATTLE(60), // 20, 40, 45, 5 + CHOICE(5), + DONE(30), + SHOP(5); private final int nextEventType; diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 8c2db901..1558795e 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -295,7 +295,7 @@ public Object getInGameDataDto(Long userId){ .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) - .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory()) ) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) .choiceInfo(inGameMapper.mapChoice(gameSessionMongo.getChoiceInfo())) .build(); @@ -726,7 +726,7 @@ public GameSessionMongo gameToDone(Long userId) { .selectedChoice(gameSessionMongo.getPreChoice()) .resultContent(RewardType.getRewardSummary(gameSessionMongo.getRewardInfo())) .playerName(gameSessionMongo.getPlayerInfo().getName()) - .playerVictory( (gameSessionMongo.getSceneType() == SceneType.BATTLE)) + .playerVictory( gameSessionMongo.getRewardInfo().getRewardLife() >= 0 ) .build(); diff --git a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java index 03a79c9d..d2e61442 100644 --- a/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java +++ b/src/main/java/com/scriptopia/demo/utils/GameBalanceUtil.java @@ -75,8 +75,6 @@ public class GameBalanceUtil { }}; - - /** * @param grade * @return (0: STR, 1: AGI, 2: INT, 3: LUCK) @@ -357,8 +355,8 @@ public static boolean isPass(int choiceProbability) { public static RewardInfoMongo getReward(RewardType rewardType, boolean isPass) { RewardInfoMongo.RewardInfoMongoBuilder builder = RewardInfoMongo.builder(); - // 기본값: 성공이면 생명 +1, 실패면 생명 -1 - builder.rewardLife(isPass ? 1 : -1); + // 기본값: 성공이면 생명 0, 실패면 생명 -1 + builder.rewardLife(isPass ? 0 : -1); switch (rewardType) { case GOLD: @@ -404,7 +402,12 @@ public static PlayerInfoMongo updateReward(PlayerInfoMongo playerInfo, RewardInf playerInfo.setLife(playerInfo.getLife() + rewardInfo.getRewardLife()); if (rewardInfo.getRewardGold() != null) - playerInfo.setGold(playerInfo.getGold() + rewardInfo.getRewardGold()); + if (playerInfo.getGold() + rewardInfo.getRewardGold() < 0){ + playerInfo.setGold(0L); + }else{ + playerInfo.setGold(playerInfo.getGold() + rewardInfo.getRewardGold()); + } + if (rewardInfo.getRewardTrait() != null) playerInfo.setTrait(rewardInfo.getRewardTrait()); diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index ef3026d7..bc4bd1db 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -46,6 +46,10 @@

로그인

+ +
@@ -54,9 +58,14 @@

로그인

Player Info

HP: 100
MP: 50
Level: 1
-
-

Inventory

-
아이템 없음
+
+

장착 중 장비

+
없음
+
+ +
+

소유 아이템

+
없음
@@ -75,9 +84,23 @@

Inventory

From 88f06a7fec3f9a31337f841571a6172f5085b629 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Sat, 27 Sep 2025 19:55:02 +0900 Subject: [PATCH 483/527] wip: during working --- .../demo/domain/ChoiceResultType.java | 4 +-- .../demo/domain/mongo/RewardInfoMongo.java | 35 ++++++++++++++----- .../demo/service/GameSessionService.java | 13 +++++-- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index 18f0f46e..a65fefe2 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -7,10 +7,10 @@ @Getter public enum ChoiceResultType { - BATTLE(60), // 20, 40, 45, 5 + BATTLE(30), // 20, 40, 45, 5 CHOICE(5), DONE(30), - SHOP(5); + SHOP(35); private final int nextEventType; diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java index 34de088e..5d01b74d 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/RewardInfoMongo.java @@ -1,7 +1,7 @@ package com.scriptopia.demo.domain.mongo; import lombok.*; - +import java.util.ArrayList; import java.util.List; @Data @@ -9,13 +9,30 @@ @AllArgsConstructor @NoArgsConstructor public class RewardInfoMongo { - private List gainedItemDefId; - private List lostItemsDefId; - private Integer rewardStrength; - private Integer rewardAgility; - private Integer rewardIntelligence; - private Integer rewardLuck; - private Integer rewardLife; + + @Builder.Default + private List gainedItemDefId = new ArrayList<>(); + + @Builder.Default + private List lostItemsDefId = new ArrayList<>(); + + @Builder.Default + private Integer rewardStrength = 0; + + @Builder.Default + private Integer rewardAgility = 0; + + @Builder.Default + private Integer rewardIntelligence = 0; + + @Builder.Default + private Integer rewardLuck = 0; + + @Builder.Default + private Integer rewardLife = 0; + private String rewardTrait; - private Integer rewardGold; + + @Builder.Default + private Integer rewardGold = 0; } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 1558795e..f781a612 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -345,6 +345,7 @@ public Object getInGameDataDto(Long userId){ .startedAt(gameSessionMongo.getStartedAt()) .updatedAt(LocalDateTime.now()) .background(gameSessionMongo.getBackground()) + .progress(gameSessionMongo.getProgress()) .location(gameSessionMongo.getLocation()) .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) @@ -421,9 +422,13 @@ public GameSessionMongo gameProgress(Long userId) { gameToDone(userId); } case SceneType.DONE -> { + gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); + gameSessionMongoRepository.save(gameSessionMongo); gameToChoice(userId); } case SceneType.SHOP -> { + gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); + gameSessionMongoRepository.save(gameSessionMongo); gameToChoice(userId); } default -> throw new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND); @@ -718,6 +723,11 @@ public GameSessionMongo gameToDone(Long userId) { GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameId) .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + SceneType preSceneType = gameSessionMongo.getSceneType(); + boolean isVictory = false; + if (preSceneType == SceneType.BATTLE) { + isVictory = gameSessionMongo.getBattleInfo().getPlayerWin(); + } CreateGameDoneRequest fastApiRequest = CreateGameDoneRequest.builder() .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) @@ -726,7 +736,7 @@ public GameSessionMongo gameToDone(Long userId) { .selectedChoice(gameSessionMongo.getPreChoice()) .resultContent(RewardType.getRewardSummary(gameSessionMongo.getRewardInfo())) .playerName(gameSessionMongo.getPlayerInfo().getName()) - .playerVictory( gameSessionMongo.getRewardInfo().getRewardLife() >= 0 ) + .playerVictory( isVictory ) .build(); @@ -742,7 +752,6 @@ public GameSessionMongo gameToDone(Long userId) { gameSessionMongo.setUpdatedAt(LocalDateTime.now()); gameSessionMongo.setLocation(fastApiResponse.getDoneInfo().getNewLocation()); gameSessionMongo.setBackground(fastApiResponse.getDoneInfo().getReCap()); - gameSessionMongo.setProgress(gameSessionMongo.getProgress() + 1); int currentProgress = gameSessionMongo.getProgress(); From 900e7d410294e6931c8ba52d5ffa33c388383d32 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 1 Oct 2025 17:10:06 +0900 Subject: [PATCH 484/527] refactor shared-gameservice sort --- gradlew | 0 .../com/scriptopia/demo/controller/SharedGameController.java | 2 +- .../java/com/scriptopia/demo/service/SharedGameService.java | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index a79c0c86..a32ed28c 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -48,7 +48,7 @@ public ResponseEntity> getPublicSharedGames @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) List tagIds, @RequestParam(required = false) String query, - @RequestParam(defaultValue = "LATEST")SharedGameSort sort) { + @RequestParam(defaultValue = "POPULAR")SharedGameSort sort) { Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); return sharedGameService.getPublicSharedGames(viewerId, lastUUID, size, tagIds, query, sort); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index aa815d76..7f83d016 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -159,7 +159,7 @@ public ResponseEntity> getPublicSharedGames // 2) 태그/커서/정렬 전처리 boolean tagEmpty = (tagIds == null || tagIds.isEmpty()); - SharedGameSort effectiveSort = qBlank ? sort : SharedGameSort.LATEST; + SharedGameSort effectiveSort = qBlank ? sort : SharedGameSort.POPULAR; boolean useCursor = (lastUuid != null); Long lastId = null; From 30e8f18edb354cf4b0ec4c86b6beb1d55fa1d3a2 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 1 Oct 2025 22:01:45 +0900 Subject: [PATCH 485/527] feat: create fast api domain --- .../demo/dto/gamesession/GameEndRequest.java | 16 ++++++++++++++++ .../demo/dto/gamesession/GameEndResponse.java | 12 ++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameEndResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java new file mode 100644 index 00000000..50bbd4fd --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java @@ -0,0 +1,16 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GameEndRequest { + private String worldView; + private String location; + private String previousStory; + private String playerName; + private String gameEnd; +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndResponse.java new file mode 100644 index 00000000..fda17c43 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndResponse.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GameEndResponse { + private String endStory; +} From 5717390602c1f54bab8712e5e17fc325ec7870c1 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Wed, 1 Oct 2025 22:02:43 +0900 Subject: [PATCH 486/527] feat: create FASTAPI endpoitn and service --- .../scriptopia/demo/config/fastapi/FastApiEndpoint.java | 3 ++- .../java/com/scriptopia/demo/service/FastApiService.java | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java index 415c05e9..08547f89 100644 --- a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java @@ -5,7 +5,8 @@ public enum FastApiEndpoint { CHOICE("/games/choice"), BATTLE("/games/battle"), ITEM("/games/item"), - DONE("/games/done"); + DONE("/games/done"), + END("/games/end"); private final String path; diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index 53ce7b56..9f39a106 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -65,5 +65,14 @@ public ItemFastApiResponse item(ItemFastApiRequest request) { .block(); } + // 게임 종료 생성 (확장용) + public GameEndResponse end(GameEndRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.END.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(GameEndResponse.class) + .block(); + } } From cca4b1306d0861c050a9eea72f3db6e405c92392 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 2 Oct 2025 15:49:52 +0900 Subject: [PATCH 487/527] refactor: add GAMEOVER, GAMECLEAR --- src/main/java/com/scriptopia/demo/domain/SceneType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/domain/SceneType.java b/src/main/java/com/scriptopia/demo/domain/SceneType.java index b001a92c..a56194b5 100644 --- a/src/main/java/com/scriptopia/demo/domain/SceneType.java +++ b/src/main/java/com/scriptopia/demo/domain/SceneType.java @@ -1,5 +1,5 @@ package com.scriptopia.demo.domain; public enum SceneType { - BATTLE, CHOICE, SHOP, DONE + BATTLE, CHOICE, SHOP, DONE, GAMEOVER, GAMECLEAR } From 4e9e74c45adb22e5cb7d04f993a12126473e020f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 2 Oct 2025 18:31:52 +0900 Subject: [PATCH 488/527] feat: add InGameClearResponse and InGameOverResponse DTOs --- .../ingame/InGameClearResponse.java | 27 ++++++++++++++++++ .../ingame/InGameOverResponse.java | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameClearResponse.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameOverResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameClearResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameClearResponse.java new file mode 100644 index 00000000..2666aeb9 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameClearResponse.java @@ -0,0 +1,27 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InGameClearResponse { + private String sceneType; + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + private String background; + private String location; + private int progress; + private int stageSize; + + private InGamePlayerResponse playerInfo; + private InGameNpcResponse npcInfo; + private List inventory; +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameOverResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameOverResponse.java new file mode 100644 index 00000000..21f06f93 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/ingame/InGameOverResponse.java @@ -0,0 +1,28 @@ +package com.scriptopia.demo.dto.gamesession.ingame; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InGameOverResponse { + private String sceneType; + private LocalDateTime startedAt; + private LocalDateTime updatedAt; + private String background; + private String location; + private int progress; + private int stageSize; + + private InGamePlayerResponse playerInfo; + private InGameNpcResponse npcInfo; + private List inventory; + +} From eef8025aa3f26e85a652c5afde265b789a4ed36a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 2 Oct 2025 18:31:53 +0900 Subject: [PATCH 489/527] refactor: update game end logic and template to support game over/clear flow --- .../demo/domain/ChoiceResultType.java | 4 +- .../demo/dto/gamesession/GameEndRequest.java | 4 +- .../demo/service/GameSessionService.java | 73 ++++++++++++++++++- src/main/resources/templates/index.html | 43 +++++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java index a65fefe2..85dcc74c 100644 --- a/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java +++ b/src/main/java/com/scriptopia/demo/domain/ChoiceResultType.java @@ -9,8 +9,8 @@ public enum ChoiceResultType { BATTLE(30), // 20, 40, 45, 5 CHOICE(5), - DONE(30), - SHOP(35); + DONE(60), + SHOP(5); private final int nextEventType; diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java index 50bbd4fd..ef44b40d 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameEndRequest.java @@ -1,9 +1,11 @@ package com.scriptopia.demo.dto.gamesession; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +@Builder @Data @NoArgsConstructor @AllArgsConstructor @@ -12,5 +14,5 @@ public class GameEndRequest { private String location; private String previousStory; private String playerName; - private String gameEnd; + private int gameEnd; } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index f781a612..eb650547 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,9 +1,6 @@ package com.scriptopia.demo.service; -import com.scriptopia.demo.dto.gamesession.ingame.InGameBattleResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGameChoiceResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGameDoneResponse; -import com.scriptopia.demo.dto.gamesession.ingame.InGameShopResponse; +import com.scriptopia.demo.dto.gamesession.ingame.*; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.mapper.InGameMapper; @@ -386,6 +383,34 @@ public Object getInGameDataDto(Long userId){ .curTurnId(battleInfo != null ? battleInfo.getCurTurnId() : null) .build(); + } else if (currentSceneType == SceneType.GAMEOVER) { + + return InGameOverResponse.builder() + .sceneType("GAMEOVER") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(gameSessionMongo.getUpdatedAt()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .progress(gameSessionMongo.getProgress()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) + .build(); + } else if (currentSceneType == SceneType.GAMECLEAR) { + + return InGameClearResponse.builder() + .sceneType("GAMECLEAR") + .startedAt(gameSessionMongo.getStartedAt()) + .updatedAt(gameSessionMongo.getUpdatedAt()) + .background(gameSessionMongo.getBackground()) + .location(gameSessionMongo.getLocation()) + .progress(gameSessionMongo.getProgress()) + .stageSize(gameSessionMongo.getStage() != null ? gameSessionMongo.getStage().size() : 0) + .playerInfo(inGameMapper.mapPlayer(gameSessionMongo.getPlayerInfo())) + .npcInfo(inGameMapper.mapNpc(gameSessionMongo.getNpcInfo())) + .inventory(inGameMapper.mapInventory(gameSessionMongo.getInventory())) + .build(); } return null; @@ -413,6 +438,19 @@ public GameSessionMongo gameProgress(Long userId) { .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + if( gameSessionMongo.getPlayerInfo().getLife() <= 0 ){ + // gameOver 메소드 구현 필요 + return gameToEnd(gameSessionMongo, 0); + } + + if ( gameSessionMongo.getProgress() > gameSessionMongo.getStage().size()){ + // gmaeClear 즉 + return gameToEnd(gameSessionMongo, 1); + + } + + + SceneType currentSceneType = gameSessionMongo.getSceneType(); switch (currentSceneType) { case SceneType.CHOICE -> { @@ -1193,4 +1231,31 @@ public void usePotion(Long userId, String ItemId) { throw new CustomException(ErrorCode.E_404_ITEM_NOT_FOUND); } } + + + /** + * 게임 종료 처리 (0 이면 gameover 1이면 gameclear + */ + private GameSessionMongo gameToEnd(GameSessionMongo gameSessionMongo, int gameOver) { + GameEndRequest fastApiRequest = GameEndRequest.builder() + .worldView(gameSessionMongo.getHistoryInfo().getWorldView()) + .location(gameSessionMongo.getLocation()) + .previousStory(gameSessionMongo.getBackground()) + .playerName(gameSessionMongo.getPlayerInfo().getName()) + .gameEnd(gameOver) + .build(); + + SceneType isGameClear = SceneType.GAMEOVER; + if ( gameOver == 1){ + isGameClear = SceneType.GAMECLEAR; + } + GameEndResponse fastApiResponse = fastApiService.end(fastApiRequest); + + gameSessionMongo.setBackground(fastApiResponse.getEndStory()); + gameSessionMongo.setSceneType(isGameClear); + gameSessionMongoRepository.save(gameSessionMongo); + + return gameSessionMongo; + } + } diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index bc4bd1db..9b0b4ab2 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -554,6 +554,49 @@

소유 아이템

} break; + case 'GAMEOVER': + choiceContainer.innerHTML = ''; + const gameOverBtn = document.createElement('button'); + gameOverBtn.className = 'btn'; + gameOverBtn.textContent = '게임 패배 - 결과 보기'; + gameOverBtn.addEventListener('click', () => { + if (data.rewardInfo) { + const r = data.rewardInfo; + choiceContainer.innerHTML = ` +
게임 패배 결과
+
획득 아이템: ${r.gainedItemNames?.join(', ') || '없음'}
+
보상: STR ${r.rewardStrength}, AGI ${r.rewardAgility}, + INT ${r.rewardIntelligence}, LUCK ${r.rewardLuck}, + Life ${r.rewardLife}, Gold ${r.rewardGold}
`; + } else { + choiceContainer.innerHTML = '
결과 없음
'; + } + }); + choiceContainer.appendChild(gameOverBtn); + break; + + + case 'GAMECLEAR': + choiceContainer.innerHTML = ''; + const gameClearBtn = document.createElement('button'); + gameClearBtn.className = 'btn'; + gameClearBtn.textContent = '게임 승리 - 결과 보기'; + gameClearBtn.addEventListener('click', () => { + if (data.rewardInfo) { + const r = data.rewardInfo; + choiceContainer.innerHTML = ` +
게임 승리 결과
+
획득 아이템: ${r.gainedItemNames?.join(', ') || '없음'}
+
보상: STR ${r.rewardStrength}, AGI ${r.rewardAgility}, + INT ${r.rewardIntelligence}, LUCK ${r.rewardLuck}, + Life ${r.rewardLife}, Gold ${r.rewardGold}
`; + } else { + choiceContainer.innerHTML = '
결과 없음
'; + } + }); + choiceContainer.appendChild(gameClearBtn); + break; + default: choiceContainer.innerHTML = '
알 수 없는 장면
'; } From c16d7f8784cc433defafaf669347f8c37f4b7133 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 2 Oct 2025 18:33:27 +0900 Subject: [PATCH 490/527] feat: add dependence org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0 --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 36d6a55b..36a2a738 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,9 @@ dependencies { // 소셜 로그인 oauth2 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' } tasks.named('test') { From b3183255f8d0025631563ce48c4b8e5f3be7e519 Mon Sep 17 00:00:00 2001 From: juns0720 Date: Thu, 2 Oct 2025 20:31:16 +0900 Subject: [PATCH 491/527] feat: implement swagger --- build.gradle | 3 +- .../scriptopia/demo/config/JwtAuthFilter.java | 8 +-- .../demo/config/LocalDataSeeder.java | 2 - .../demo/config/SecurityWhitelist.java | 5 ++ .../scriptopia/demo/config/SwaggerConfig.java | 37 ++++++++++++ .../demo/config/SwaggerExampleConfig.java | 44 ++++++++++++++ .../demo/controller/AuctionController.java | 10 ++++ .../demo/controller/AuthController.java | 20 ++++--- .../controller/GameSessionController.java | 59 +++++++++++-------- .../demo/controller/ItemController.java | 5 +- .../demo/controller/OAuthController.java | 7 ++- .../demo/controller/PiaShopController.java | 9 ++- .../demo/controller/SharedGameController.java | 13 ++++ .../demo/controller/TestEnvController.java | 2 + .../demo/controller/UserController.java | 12 ++++ .../demo/controller/refreshController.java | 4 ++ .../demo/dto/auth/LoginRequest.java | 4 ++ src/main/resources/application.yml | 11 ++++ 18 files changed, 210 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/config/SwaggerConfig.java create mode 100644 src/main/java/com/scriptopia/demo/config/SwaggerExampleConfig.java diff --git a/build.gradle b/build.gradle index 36a2a738..7ed6c563 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.4' + id 'org.springframework.boot' version '3.3.5' id 'io.spring.dependency-management' version '1.1.7' } @@ -63,6 +63,7 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'org.apache.commons:commons-lang3:3.18.0' } tasks.named('test') { diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 5072b6de..6a09ac6f 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -49,13 +49,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce Arrays.stream(SecurityWhitelist.PUBLIC_GETS) .anyMatch(pattern -> pathMatcher.match(pattern, path)); - boolean skip = authMatch || publicGetMatch; - - if (skip) { - log.debug("➡️ Skipping JwtAuthFilter for whitelisted request: {} {}", method, path); - } - - return skip; + return authMatch || publicGetMatch; } diff --git a/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java b/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java index ed2075bd..6e8e6723 100644 --- a/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java +++ b/src/main/java/com/scriptopia/demo/config/LocalDataSeeder.java @@ -57,11 +57,9 @@ public class LocalDataSeeder implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments args) { - log.info("=== LocalDataSeeder: start ==="); // 0) 이미 데이터가 있으면 중복 시드 방지 if (userRepository.count() > 1) { - log.info("Users already exist. Skip seeding."); return; } diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index d3f7811f..a1f0140e 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -15,9 +15,14 @@ public class SecurityWhitelist { "/oauth/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/shops/pia/items" + + }; public static final String[] PUBLIC_GETS = { diff --git a/src/main/java/com/scriptopia/demo/config/SwaggerConfig.java b/src/main/java/com/scriptopia/demo/config/SwaggerConfig.java new file mode 100644 index 00000000..767d7f1d --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.scriptopia.demo.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI OpenAPI() { + return new OpenAPI() + .info(new Info().title("Scriptopia API").version("1.0")) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .name("bearerAuth") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ); + } + + private Info ApiInfo() { + return new Info() + .title("Scriptopia Swagger") + .description("Scriptopia 공식 API 문서입니다.") + .version("1.0.0"); + + } +} diff --git a/src/main/java/com/scriptopia/demo/config/SwaggerExampleConfig.java b/src/main/java/com/scriptopia/demo/config/SwaggerExampleConfig.java new file mode 100644 index 00000000..5261a223 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/config/SwaggerExampleConfig.java @@ -0,0 +1,44 @@ +package com.scriptopia.demo.config; + +import com.scriptopia.demo.dto.auth.LoginRequest; +import io.swagger.v3.oas.models.examples.Example; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerExampleConfig { + + + @Value("${app.admin.username}") + private String adminUsername; + + @Value("${app.admin.password}") + private String adminPassword; + + + @Bean + public OpenApiCustomizer customiseExamples() { + return openApi -> { + if (openApi.getPaths() == null) return; + + openApi.getPaths().forEach((path, item) -> { + if (path.endsWith("/auth/login") && item.getPost() != null) { + var reqBody = item.getPost().getRequestBody(); + if (reqBody == null) return; + + var content = reqBody.getContent().get("application/json"); + if (content == null) return; + + // DTO 객체 그대로 넣기 + content.addExamples("어드민 계정", + new Example().value(new LoginRequest(adminUsername, adminPassword, "1234"))); + + content.addExamples("일반 유저 계정", + new Example().value(new LoginRequest("userA@example.com", "userA!234", "1234"))); + } + }); + }; + } +} diff --git a/src/main/java/com/scriptopia/demo/controller/AuctionController.java b/src/main/java/com/scriptopia/demo/controller/AuctionController.java index 0e5501fd..6e25b7b5 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuctionController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuctionController.java @@ -3,6 +3,8 @@ import com.scriptopia.demo.dto.auction.*; import com.scriptopia.demo.service.AuctionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -11,11 +13,13 @@ @RestController @RequiredArgsConstructor +@Tag(name = "거래 API", description = "경매장 관련 거래 API 입니다.") @RequestMapping("/trades") public class AuctionController { private final AuctionService auctionService; + @Operation(summary = "보유 장비 아이템 조회") @GetMapping public ResponseEntity getTrades( @RequestBody TradeFilterRequest requestDto) { @@ -25,6 +29,7 @@ public ResponseEntity getTrades( } + @Operation(summary = "경매장 아이템 구매") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{auctionId}/purchase") public ResponseEntity purchaseItem( @@ -37,6 +42,7 @@ public ResponseEntity purchaseItem( return ResponseEntity.ok(result); } + @Operation(summary = "내가 등록한 판매 아이템 조회") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/me") public ResponseEntity mySaleItems( @@ -49,6 +55,7 @@ public ResponseEntity mySaleItems( return ResponseEntity.ok(result); } + @Operation(summary = "경매장 아이템 판매 등록") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping public ResponseEntity createAuction(@RequestBody AuctionRequest dto, @@ -58,6 +65,7 @@ public ResponseEntity createAuction(@RequestBody AuctionRequest dto, return ResponseEntity.ok(auctionService.createAuction(dto, userId)); } + @Operation(summary = "판매 중인 아이템 등록 취소") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @DeleteMapping("/{auctionId}") public ResponseEntity cancelMySaleItem( @@ -69,6 +77,7 @@ public ResponseEntity cancelMySaleItem( return ResponseEntity.ok(result); } + @Operation(summary = "내 거래 기록 조회(정산 테이블 조회)") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/me/history") public ResponseEntity settlementHistory( @@ -81,6 +90,7 @@ public ResponseEntity settlementHistory( return ResponseEntity.ok(result); } + @Operation(summary = "구매 아이템/판매 대금 수령") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{settlementId}/confirm") public ResponseEntity confirmItem( diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 9400b495..8aaf83e3 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -3,6 +3,8 @@ import com.scriptopia.demo.dto.auth.*; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.service.RefreshTokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -15,17 +17,17 @@ @RestController @RequestMapping("/auth") +@Tag(name = "로컬 인증 API", description = "로컬 인증 관련 API 입니다.") @RequiredArgsConstructor public class AuthController { private final LocalAccountService localAccountService; private final RefreshTokenService refreshTokenService; - private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; - + @Operation(summary = "로그아웃") @PostMapping("/logout") public ResponseEntity logout( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, @@ -38,7 +40,7 @@ public ResponseEntity logout( return ResponseEntity.ok("로그아웃 되었습니다."); } - + @Operation(summary = "로컬 로그인") @PostMapping("/login") public ResponseEntity login( @RequestBody @Valid LoginRequest req, @@ -49,6 +51,7 @@ public ResponseEntity login( return ResponseEntity.ok(localAccountService.login(req, request, response)); } + @Operation(summary = "로컬 계정 회원가입") @PostMapping("/register") public ResponseEntity register( @RequestBody @Valid RegisterRequest request @@ -57,6 +60,7 @@ public ResponseEntity register( return ResponseEntity.ok("회원가입에 성공했습니다."); } + @Operation(summary = "이메일 중복 검증") @PostMapping("/email/verify") public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { @@ -65,21 +69,21 @@ public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest requ return ResponseEntity.ok("사용 가능한 이메일입니다."); } - + @Operation(summary = "이메일 인증 코드 전송") @PostMapping("/email/code/send") public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { localAccountService.sendVerificationCode(request.getEmail()); return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); } + @Operation(summary = "이메일 인증 코드 확인") @PostMapping("/email/code/verify") public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { localAccountService.verifyCode(request.getEmail(), request.getCode()); return ResponseEntity.ok("이메일 인증이 완료되었습니다."); - } - + @Operation(summary = "비밀번호 초기화 링크 발송") @PostMapping("/password/reset/send") public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ @@ -88,7 +92,7 @@ public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest reque return ResponseEntity.ok("비밀번호 초기화 링크를 전송했습니다."); } - + @Operation(summary = "비밀번호 초기화") @PatchMapping("/password/reset") public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { localAccountService.resetPassword(request.getToken(), request.getNewPassword()); @@ -96,7 +100,7 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); } - + @Operation(summary = "비밀번호 재설정") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PatchMapping("/password/change") public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 482c0453..ff3670f3 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -6,6 +6,8 @@ import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.service.GameSessionService; import com.scriptopia.demo.service.HistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -14,6 +16,7 @@ @RestController @RequestMapping("/games") +@Tag(name = "게임 세션 API", description = "게임 세션 관련 API 입니다.") @RequiredArgsConstructor public class GameSessionController { @@ -23,6 +26,7 @@ public class GameSessionController { /* * 게임 -> 게임 도중 종료 */ + @Operation(summary = "저장 후 게임 종료") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/exit") public ResponseEntity createGameSession(Authentication authentication, @RequestBody GameSessionRequest request) { @@ -33,15 +37,26 @@ public ResponseEntity createGameSession(Authentication authentication, @Reque /* * 게임 -> 기존 게임 삭제 */ + @Operation(summary = "기존 게임 삭제") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping() public ResponseEntity deleteGameSession(Authentication authentication, @RequestBody GameSessionRequest request) { Long userId = Long.valueOf(authentication.getName()); return gameSessionService.deleteGameSession(userId, request.getGameId()); } - - + + /* + * 게임 -> 기존 게임 조회 + */ + @Operation(summary = "기존 게임 조회") + @GetMapping("/me") + public ResponseEntity loadGameSession(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + return gameSessionService.getGameSession(userId); + } + // 게임 시작 + @Operation(summary = "새 게임 생성") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping public ResponseEntity startNewGame( @@ -54,7 +69,7 @@ public ResponseEntity startNewGame( return ResponseEntity.ok(response); } - + @Operation(summary = "게임 진입") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/{gameId}") public ResponseEntity getInGameData( @@ -69,34 +84,36 @@ public ResponseEntity getInGameData( return ResponseEntity.ok(response); } - + @Operation(summary = "선택지 선택") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/{gameId}/progress") - public ResponseEntity keepGame( + @PostMapping("/{gameId}/select") + public ResponseEntity selectChoice( @PathVariable("gameId") String gameId, + @RequestBody GameChoiceRequest request, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - GameSessionMongo response = gameSessionService.gameProgress(userId); + GameSessionMongo response = gameSessionService.gameChoiceSelect(userId, request); + return ResponseEntity.ok(response); } - + @Operation(summary = "게임 진행") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") - @PostMapping("/{gameId}/select") - public ResponseEntity selectChoice( + @PostMapping("/{gameId}/progress") + public ResponseEntity keepGame( @PathVariable("gameId") String gameId, - @RequestBody GameChoiceRequest request, Authentication authentication) throws JsonProcessingException { Long userId = Long.valueOf(authentication.getName()); - GameSessionMongo response = gameSessionService.gameChoiceSelect(userId, request); - + GameSessionMongo response = gameSessionService.gameProgress(userId); return ResponseEntity.ok(response); } + + @Operation(summary = "아이템 장착/해제") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/equipItem/{gameId}/{itemId}") public ResponseEntity equipItem( @@ -111,6 +128,7 @@ public ResponseEntity equipItem( return ResponseEntity.ok(response); } + @Operation(summary = "아이템 버리기") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @DeleteMapping("/dropItem/{gameId}/{itemId}") public ResponseEntity dropItem( @@ -125,6 +143,7 @@ public ResponseEntity dropItem( return ResponseEntity.ok(response); } + @Operation(summary = "아이템 구매") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{gameId}/items/purchase/{itemId}") public ResponseEntity buyItem( @@ -139,6 +158,7 @@ public ResponseEntity buyItem( return ResponseEntity.ok(response); } + @Operation(summary = "아이템 판매") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/{gameId}/items/sell/{itemId}") public ResponseEntity sellItem( @@ -153,17 +173,6 @@ public ResponseEntity sellItem( return ResponseEntity.ok(response); } - /* - * 게임 -> 기존 게임 조회 - */ - @GetMapping("/me") - public ResponseEntity loadGameSession(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - return gameSessionService.getGameSession(userId); - } - - - /** * 현재는 userId, sessionId를 통해 저장하는데 * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 @@ -171,6 +180,7 @@ public ResponseEntity loadGameSession(Authentication authentication) { /* * 게임 -> 히스토리 생성 */ + @Operation(summary = "") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/history") public ResponseEntity addHistory(Authentication authentication, @RequestBody GameSessionRequest request) { @@ -179,6 +189,7 @@ public ResponseEntity addHistory(Authentication authentication, @RequestBody } /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ + @Operation(summary = "") @PostMapping("/history/seed") public ResponseEntity seed(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/controller/ItemController.java b/src/main/java/com/scriptopia/demo/controller/ItemController.java index cecdbb2c..b872fc3d 100644 --- a/src/main/java/com/scriptopia/demo/controller/ItemController.java +++ b/src/main/java/com/scriptopia/demo/controller/ItemController.java @@ -3,6 +3,8 @@ import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.service.ItemService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -11,12 +13,13 @@ @RestController @RequestMapping("/items") +@Tag(name = "아이템 관련 API", description = "아이템 관련 API 입니다.") @RequiredArgsConstructor public class ItemController { private final ItemService itemService; - + @Operation(summary = "어드민 테스트용 아이템 생성") @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping public ResponseEntity createItem( diff --git a/src/main/java/com/scriptopia/demo/controller/OAuthController.java b/src/main/java/com/scriptopia/demo/controller/OAuthController.java index f065dbcd..dcb73650 100644 --- a/src/main/java/com/scriptopia/demo/controller/OAuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/OAuthController.java @@ -4,6 +4,8 @@ import com.scriptopia.demo.dto.oauth.OAuthLoginResponse; import com.scriptopia.demo.dto.oauth.SocialSignupRequest; import com.scriptopia.demo.service.OAuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,16 +14,19 @@ @RestController @RequestMapping("/oauth") +@Tag(name = "소셜 인증 관련 API", description = "소셜 인증 관련 API 입니다.") @RequiredArgsConstructor public class OAuthController { private final OAuthService oAuthService; + @Operation(summary = "Oauth 로그인 url 발급") @GetMapping("/authorize") public ResponseEntity getAuthorizationUrl(@RequestParam("provider") String provider) { return ResponseEntity.ok(oAuthService.buildAuthorizationUrl(provider)); } + @Operation(summary = "소셜 로그인") @GetMapping("/{provider}") public ResponseEntity login( @PathVariable("provider") String provider, @@ -32,7 +37,7 @@ public ResponseEntity login( OAuthLoginResponse result = oAuthService.login(provider, code, request, response); return ResponseEntity.ok(result); } - + @Operation(summary = "소셜 회원가입") @PostMapping("/register") public ResponseEntity signup( @RequestBody SocialSignupRequest req, diff --git a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java index 8411b9f4..6690cfa5 100644 --- a/src/main/java/com/scriptopia/demo/controller/PiaShopController.java +++ b/src/main/java/com/scriptopia/demo/controller/PiaShopController.java @@ -8,6 +8,8 @@ import com.scriptopia.demo.dto.piashop.PurchasePiaItemRequest; import com.scriptopia.demo.service.ItemService; import com.scriptopia.demo.service.PiaShopService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -18,11 +20,13 @@ @RestController @RequiredArgsConstructor +@Tag(name = "피아 상점 관련 API", description = "피아 상점 관련 API 입니다.") @RequestMapping("/shops") public class PiaShopController { private final PiaShopService piaShopService; private final ItemService itemService; + @Operation(summary = "PIA 상품 등록") @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping("/items/pia") public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) { @@ -30,7 +34,7 @@ public ResponseEntity createPiaItem(@RequestBody PiaItemRequest request) return ResponseEntity.ok("PIA 아이템이 등록되었습니다."); } - + @Operation(summary = "PIA 상품 수정") @PreAuthorize("hasAnyAuthority('ADMIN')") @PutMapping("/items/pia/{itemId}") public ResponseEntity updatePiaItem( @@ -42,11 +46,13 @@ public ResponseEntity updatePiaItem( return ResponseEntity.ok(result); } + @Operation(summary = "PIA 판매 상품 목록 조회") @GetMapping("/pia/items") public ResponseEntity> getPiaItems() { return ResponseEntity.ok(piaShopService.getPiaItems()); } + @Operation(summary = "PIA 상품 구매") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/pia/purchase") public ResponseEntity purchasePiaItem( @@ -58,6 +64,7 @@ public ResponseEntity purchasePiaItem( return ResponseEntity.ok("PIA 아이템을 구매했습니다."); } + @Operation(summary = "아이템 모루 사용") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/pia/items/anvil") public ResponseEntity useItemAnvil( diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index a32ed28c..34ffebb3 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -11,6 +11,8 @@ import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; import com.scriptopia.demo.service.TagDefService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +24,7 @@ @RestController @RequestMapping("/shared-games") +@Tag(name = "게임 공유 관련 API", description = "게임 공유 관련 API 입니다.") @RequiredArgsConstructor public class SharedGameController { private final SharedGameService sharedGameService; @@ -31,6 +34,8 @@ public class SharedGameController { /* 게임 공유 -> 게임 공유하기 */ + + @Operation(summary = "게임 공유하기") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping public ResponseEntity share(Authentication authentication, @RequestBody SharedGameRequest req) { @@ -42,6 +47,7 @@ public ResponseEntity share(Authentication authentication, @RequestBody Share /* 게임 공유 -> 공유 게임 목록 조회 */ + @Operation(summary = "공유 게임 목록 조회") @GetMapping public ResponseEntity> getPublicSharedGames(Authentication authentication, @RequestParam(required = false) UUID lastUUID, @@ -56,6 +62,7 @@ public ResponseEntity> getPublicSharedGames /* 게임공유 : 공유된 게임 상세 조회 */ + @Operation(summary = "공유 게임 상세 조회") @GetMapping("/{sharedGameId}") public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID sharedGameId) { return sharedGameService.getDetailedSharedGame(sharedGameId); @@ -64,6 +71,7 @@ public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID /* 게임공유 : 공유 게임 Like 요청 */ + @Operation(summary = "공유 게임 Like 요청") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("{sharedGameId}/like") public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") UUID sharedGameId, Authentication authentication) { @@ -75,6 +83,7 @@ public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") UUID share /* 게임공유 : 공유된 게임 태그 조회 */ + @Operation(summary = "게임 태그 조회") @GetMapping("/tags") public ResponseEntity getSharedGameTags() { return sharedGameService.getTag(); @@ -83,6 +92,7 @@ public ResponseEntity getSharedGameTags() { /* 게임 공유 -> 공유한 게임 삭제 */ + @Operation(summary = "공유한 게임 삭제") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @DeleteMapping("/shared-games") public ResponseEntity delete(Authentication authentication, @RequestBody SharedGameRequest req) { @@ -96,6 +106,7 @@ public ResponseEntity delete(Authentication authentication, @RequestBody Shar /* 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) */ + @Operation(summary = "(내가)공유한 게임 조회") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @GetMapping("/me") public ResponseEntity getMySharedGames(Authentication authentication) { @@ -104,6 +115,7 @@ public ResponseEntity getMySharedGames(Authentication authentication) { return sharedGameService.getMySharedGames(userId); } + @Operation(summary = "게임 태그 생성") @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping("/tags") public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { @@ -111,6 +123,7 @@ public ResponseEntity addTag(@RequestBody TagDefCreateRequest req) { return tagDefService.addTagName(req); } + @Operation(summary = "게임 태그 삭제 ") @PreAuthorize("hasAnyAuthority('ADMIN')") @DeleteMapping("/tags") public ResponseEntity removeTag(@RequestBody TagDefDeleteRequest req) { diff --git a/src/main/java/com/scriptopia/demo/controller/TestEnvController.java b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java index 759c16e1..a7a11459 100644 --- a/src/main/java/com/scriptopia/demo/controller/TestEnvController.java +++ b/src/main/java/com/scriptopia/demo/controller/TestEnvController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -7,6 +8,7 @@ @Controller +@Tag(name = "백엔드 정적 페이지 관련 API", description = "백엔드 정적 페이지 관련 API 입니다.") public class TestEnvController { @GetMapping("/") public String mainPage() { diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index b330575c..75bdca5f 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -8,6 +8,8 @@ import com.scriptopia.demo.service.HistoryService; import com.scriptopia.demo.service.UserCharacterImgService; import com.scriptopia.demo.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -21,6 +23,7 @@ @RestController @RequestMapping("/users/me") +@Tag(name = "유저 관련 API", description = "유저 관련 API 입니다.") @RequiredArgsConstructor public class UserController { @@ -28,6 +31,7 @@ public class UserController { private final HistoryService historyService; private final UserCharacterImgService userCharacterImgService; + @Operation(summary = "보유 장비 아이템 조회") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/items/game") public ResponseEntity> getGameItems( @@ -38,6 +42,7 @@ public ResponseEntity> getGameItems( return ResponseEntity.ok(response); } + @Operation(summary = "보유 피아 아이템 조회") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/items/pia") public ResponseEntity> getPiaItems( @@ -48,6 +53,7 @@ public ResponseEntity> getPiaItems( return ResponseEntity.ok(response); } + @Operation(summary = "사용자 옵션 조회") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/settings") public ResponseEntity getUserSettings( @@ -58,6 +64,7 @@ public ResponseEntity getUserSettings( return ResponseEntity.ok(response); } + @Operation(summary = "사용자 옵션 변경") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PutMapping("/settings") public ResponseEntity updateUserSettings( @@ -69,6 +76,7 @@ public ResponseEntity updateUserSettings( return ResponseEntity.ok("사용자 설정이 변경되었습니다."); } + @Operation(summary = "사용자 재화 조회") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @GetMapping("/assets") public ResponseEntity getUserAssets( @@ -79,6 +87,7 @@ public ResponseEntity getUserAssets( return ResponseEntity.ok(response); } + @Operation(summary = "사용자 게임 기록 조회") @GetMapping("/games/histories") public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, @RequestParam(defaultValue = "10") int size, @@ -88,6 +97,7 @@ public ResponseEntity> getHistory(@RequestParam(requir return historyService.fetchMyHistory(userId, lastId, size); } + @Operation(summary = "프로필 이미지 변경") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/profile-images/url") public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { @@ -96,6 +106,7 @@ public ResponseEntity saveUserCharacterImg(Authentication authentication, @Re return userCharacterImgService.saveUserCharacterImg(userId, url); } + @Operation(summary = "프로필 등록할 수 있는 이미지 조회") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @GetMapping("/images") public ResponseEntity getUserCharacterImgs(Authentication authentication) { @@ -107,6 +118,7 @@ public ResponseEntity getUserCharacterImgs(Authentication authentication) { /* 등록할 수 있는 이미지 저장 */ + @Operation(summary = "등록할 수 있는 이미지 저장") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/save/img") public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java index 875dc9a3..e1904024 100644 --- a/src/main/java/com/scriptopia/demo/controller/refreshController.java +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -7,6 +7,8 @@ import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.service.RefreshTokenService; import com.scriptopia.demo.utils.JwtProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -19,6 +21,7 @@ @RestController @RequestMapping("/token") +@Tag(name = "리프레쉬 토큰 관련 API", description = "리프레쉬 토큰 관련 API 입니다.") @RequiredArgsConstructor public class refreshController { @@ -31,6 +34,7 @@ public class refreshController { private static final boolean COOKIE_SECURE = true; private static final String COOKIE_SAMESITE = "None"; + @Operation(summary = "리프레시 토큰 재발급") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/refresh") public ResponseEntity refresh( diff --git a/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java index 6e6c7df7..26a6a129 100644 --- a/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/LoginRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; @@ -13,12 +14,15 @@ public class LoginRequest { @NotBlank(message = "E_400_MISSING_EMAIL") @Email(message = "E_400_INVALID_EMAIL_FORMAT") + @Schema(description = "사용자 아이디", example = "userA@example.com") private String email; @NotBlank(message = "E_400_MISSING_PASSWORD") + @Schema(description = "비밀번호", example = "userA!234") private String password; @NotBlank(message = "디바이스 식별값이 필요합니다.") + @Schema(description = "디바이스 아이디", example = "1234") private String deviceId; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 08409047..0f1419c7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,6 +57,8 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} scope: name email profile_image + + auth: jwt: issuer: scriptopia @@ -69,5 +71,14 @@ app: username: ${ADMIN_NAME} password: ${ADMIN_PASSWORD} +server: + servlet: + context-path: /api/v1 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui + image-dir: ./uploads/ image-url-prefix: /images \ No newline at end of file From 2552c3972d0cb251ab0e7fb6b46f9f8627b0a14e Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 2 Oct 2025 21:14:56 +0900 Subject: [PATCH 492/527] feat: add historyMethod when gameOver and gameClear refactor: update playerHp when ARMOR equiqqed unequiqqed --- .../controller/GameSessionController.java | 16 ++- .../demo/domain/mongo/HistoryInfoMongo.java | 2 +- .../demo/dto/history/HistoryRequest.java | 6 + .../demo/dto/history/HistoryResponse.java | 7 ++ .../demo/service/GameSessionService.java | 73 ++++++++++++- .../demo/service/HistoryService.java | 2 +- src/main/resources/templates/index.html | 103 +++++++++++++----- 7 files changed, 180 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 482c0453..348b11f3 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -1,9 +1,9 @@ package com.scriptopia.demo.controller; import com.fasterxml.jackson.core.JsonProcessingException; -import com.scriptopia.demo.domain.GameSession; import com.scriptopia.demo.domain.mongo.GameSessionMongo; import com.scriptopia.demo.dto.gamesession.*; +import com.scriptopia.demo.dto.history.HistoryResponse; import com.scriptopia.demo.service.GameSessionService; import com.scriptopia.demo.service.HistoryService; import lombok.RequiredArgsConstructor; @@ -184,4 +184,18 @@ public ResponseEntity seed(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return historyService.seedDummySession(userId); } + + + /* + * 게임 종료 후 -> 히스토리 생성 + */ + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @PostMapping("/{gameId}/history") + public ResponseEntity addHistory( + Authentication authentication, + @PathVariable("gameId") String gameId) { + Long userId = Long.valueOf(authentication.getName()); + return gameSessionService.gameToEnd(userId); + } + } diff --git a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java index 217d7e2e..cd5dc993 100644 --- a/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java +++ b/src/main/java/com/scriptopia/demo/domain/mongo/HistoryInfoMongo.java @@ -17,5 +17,5 @@ public class HistoryInfoMongo { private String epilogue2Content; private String epilogue3Title; private String epilogue3Content; - private Integer score; + private Long score; } diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java index 01cdb1e8..3954cc7b 100644 --- a/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryRequest.java @@ -1,8 +1,14 @@ package com.scriptopia.demo.dto.history; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class HistoryRequest { private String thumbnailUrl; private String title; diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java index 050f69b2..cf794a39 100644 --- a/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java @@ -1,10 +1,17 @@ package com.scriptopia.demo.dto.history; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; + +@Builder @Data +@NoArgsConstructor +@AllArgsConstructor public class HistoryResponse { private Long id; private Long userId; diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index eb650547..fe6ef215 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1,6 +1,8 @@ package com.scriptopia.demo.service; import com.scriptopia.demo.dto.gamesession.ingame.*; +import com.scriptopia.demo.dto.history.HistoryRequest; +import com.scriptopia.demo.dto.history.HistoryResponse; import com.scriptopia.demo.dto.items.ItemDefRequest; import com.scriptopia.demo.dto.items.ItemFastApiResponse; import com.scriptopia.demo.mapper.InGameMapper; @@ -41,6 +43,7 @@ public class GameSessionService { private final ItemService itemService; private final InGameMapper inGameMapper; private final ItemDefRepository itemDefRepository; + private final HistoryRepository historyRepository; public ResponseEntity getGameSession(Long userid) { @@ -590,8 +593,9 @@ public GameSessionMongo gameToChoice(Long userId) { .luck(npcStat[3]) .build(); - gameSessionMongo.setNpcInfo(npcInfoMongo); } + gameSessionMongo.setNpcInfo(npcInfoMongo); + List choiceList = new ArrayList<>(); @@ -635,6 +639,7 @@ public GameSessionMongo gameToChoice(Long userId) { } /** + * 배틍 * @param userId * @return win? */ @@ -1109,6 +1114,9 @@ private void addStats(PlayerInfoMongo player, ItemDefMongo item) { player.setAgility(player.getAgility() + safeStat(item.getAgility())); player.setIntelligence(player.getIntelligence() + safeStat(item.getIntelligence())); player.setLuck(player.getLuck() + safeStat(item.getLuck())); + if (item.getCategory() == ItemType.ARMOR){ + player.setLife( item.getBaseStat() ); + } } private void removeStats(PlayerInfoMongo player, ItemDefMongo item) { @@ -1116,6 +1124,9 @@ private void removeStats(PlayerInfoMongo player, ItemDefMongo item) { player.setAgility(player.getAgility() - safeStat(item.getAgility())); player.setIntelligence(player.getIntelligence() - safeStat(item.getIntelligence())); player.setLuck(player.getLuck() - safeStat(item.getLuck())); + if (item.getCategory() == ItemType.ARMOR){ + player.setLife(80); // 추후 스탯에 따른 체력을 한다면 + } } private int safeStat(Integer stat) { @@ -1258,4 +1269,64 @@ private GameSessionMongo gameToEnd(GameSessionMongo gameSessionMongo, int gameOv return gameSessionMongo; } + + @Transactional + public ResponseEntity gameToEnd(Long userId) { + GameSession gameSession = gameSessionRepository.findByMongoId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + GameSessionMongo gameSessionMongo = gameSessionMongoRepository.findById(gameSession.getMongoId()) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_GAME_SESSION_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + + HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); + + + HistoryRequest historyRequest = HistoryRequest.builder() + .thumbnailUrl(null) // 필요 시 + .title(historyInfoMongo.getTitle()) + .worldView(historyInfoMongo.getWorldView()) + .backgroundStory(historyInfoMongo.getBackgroundStory()) + .worldPrompt(historyInfoMongo.getWorldPrompt()) + .epilogue1Title(historyInfoMongo.getEpilogue1Title()) + .epilogue1Content(historyInfoMongo.getEpilogue1Content()) + .epilogue2Title(historyInfoMongo.getEpilogue2Title()) + .epilogue2Content(historyInfoMongo.getEpilogue2Content()) + .epilogue3Title(historyInfoMongo.getEpilogue3Title()) + .epilogue3Content(historyInfoMongo.getEpilogue3Content()) + .score(historyInfoMongo.getScore()) + .build(); + + + History history = new History(user, historyRequest); + historyRepository.save(history); + + + HistoryResponse historyResponse = HistoryResponse.builder() + .id(history.getId()) + .userId(user.getId()) + .thumbnailUrl(history.getThumbnailUrl()) + .title(history.getTitle()) + .worldView(history.getWorldView()) + .backgroundStory(history.getBackgroundStory()) + .worldPrompt(history.getWorldPrompt()) + .epilogue1Title(history.getEpilogue1Title()) + .epilogue1Content(history.getEpilogue1Content()) + .epilogue2Title(history.getEpilogue2Title()) + .epilogue2Content(history.getEpilogue2Content()) + .epilogue3Title(history.getEpilogue3Title()) + .epilogue3Content(history.getEpilogue3Content()) + .score(history.getScore()) + .createdAt(history.getCreatedAt()) + .isShared(history.getIsShared()) + .build(); + + + return ResponseEntity.ok(historyResponse); + } + + } diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java index 1ebaf343..35459fa9 100644 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ b/src/main/java/com/scriptopia/demo/service/HistoryService.java @@ -147,4 +147,4 @@ public ResponseEntity> fetchMyHistory(Long userId, UUI return ResponseEntity.ok(page.getContent().stream().map(HistoryPageResponse::from).toList()); } -} +} \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 9b0b4ab2..cdf14e0b 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -115,6 +115,44 @@

소유 아이템

const getInGameDataBtn = document.getElementById('getInGameDataBtn'); const keepGameBtn = document.getElementById('keepGameBtn'); + + const historyModal = document.getElementById('historyModal'); + const closeHistoryModal = document.getElementById('closeHistoryModal'); + + closeHistoryModal.onclick = () => historyModal.style.display = 'none'; + window.onclick = (event) => { if(event.target === historyModal) historyModal.style.display = 'none'; }; + + const showHistoryModal = (history) => { + if(!history) return; + document.getElementById('historyModalTitle').textContent = history.title || '제목 없음'; + document.getElementById('historyModalWorldView').textContent = history.worldView || ''; + document.getElementById('historyModalBackground').textContent = history.backgroundStory || ''; + document.getElementById('historyModalScore').textContent = `점수: ${history.score || 0}`; + + const epiloguesDiv = document.getElementById('historyModalEpilogues'); + epiloguesDiv.innerHTML = ''; + for(let i=1;i<=3;i++){ + const title = history[`epilogue${i}Title`]; + const content = history[`epilogue${i}Content`]; + if(title || content){ + const div = document.createElement('div'); + div.innerHTML = `

${title || ''}

${content || ''}

`; + epiloguesDiv.appendChild(div); + } + } + + historyModal.style.display = 'block'; + }; + + + + + + + + + + // 초기 로그인 상태 반영 if (accessToken) { loginContainer.style.display = 'none'; @@ -311,6 +349,26 @@

소유 아이템

} }; + // 게임 히스토리 API 호출 + const loadGameHistory = async (gameId) => { + if (!accessToken) { + alert('로그인 필요'); + return; + } + + try { + const history = await safeFetch(null, `/api/v1/games/${gameId}/history`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + accessToken } + }); + + console.log('게임 히스토리:', history); + return history; + } catch (err) { + console.error(err); + alert('게임 히스토리 불러오기 오류: ' + err.message); + } + }; const updateGameUI = (data) => { @@ -472,11 +530,12 @@

소유 아이템

switch (data.sceneType) { case 'CHOICE': + console.log(data) if (Array.isArray(data.choiceInfo)) { data.choiceInfo.forEach((choice, idx) => { const btn = document.createElement('button'); btn.className = 'btn'; - btn.textContent = `${idx}. ${choice.detail}`; + btn.textContent = `${idx+1}. ${choice.detail}. ${choice.stats}. ${choice.probability}.`; btn.addEventListener('click', () => sendChoice(idx)); choiceContainer.appendChild(btn); }); @@ -559,18 +618,9 @@

소유 아이템

const gameOverBtn = document.createElement('button'); gameOverBtn.className = 'btn'; gameOverBtn.textContent = '게임 패배 - 결과 보기'; - gameOverBtn.addEventListener('click', () => { - if (data.rewardInfo) { - const r = data.rewardInfo; - choiceContainer.innerHTML = ` -
게임 패배 결과
-
획득 아이템: ${r.gainedItemNames?.join(', ') || '없음'}
-
보상: STR ${r.rewardStrength}, AGI ${r.rewardAgility}, - INT ${r.rewardIntelligence}, LUCK ${r.rewardLuck}, - Life ${r.rewardLife}, Gold ${r.rewardGold}
`; - } else { - choiceContainer.innerHTML = '
결과 없음
'; - } + gameOverBtn.addEventListener('click', async () => { + const history = await loadGameHistory(gameId); + showHistoryModal(history); // ✅ 모달로 표시 }); choiceContainer.appendChild(gameOverBtn); break; @@ -581,18 +631,9 @@

소유 아이템

const gameClearBtn = document.createElement('button'); gameClearBtn.className = 'btn'; gameClearBtn.textContent = '게임 승리 - 결과 보기'; - gameClearBtn.addEventListener('click', () => { - if (data.rewardInfo) { - const r = data.rewardInfo; - choiceContainer.innerHTML = ` -
게임 승리 결과
-
획득 아이템: ${r.gainedItemNames?.join(', ') || '없음'}
-
보상: STR ${r.rewardStrength}, AGI ${r.rewardAgility}, - INT ${r.rewardIntelligence}, LUCK ${r.rewardLuck}, - Life ${r.rewardLife}, Gold ${r.rewardGold}
`; - } else { - choiceContainer.innerHTML = '
결과 없음
'; - } + gameClearBtn.addEventListener('click', async () => { + const history = await loadGameHistory(gameId); + showHistoryModal(history); // ✅ 모달로 표시 }); choiceContainer.appendChild(gameClearBtn); break; @@ -605,5 +646,17 @@

소유 아이템

}); + + + From 2bfb29ac3fd2f633b77d0691217cf5c37e277da5 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 2 Oct 2025 21:55:41 +0900 Subject: [PATCH 493/527] before merge --- .../com/scriptopia/demo/controller/GameSessionController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 01362f33..1780e326 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -200,6 +200,7 @@ public ResponseEntity seed(Authentication authentication) { /* * 게임 종료 후 -> 히스토리 생성 */ + @Operation(summary = "게임 종료 후 히스토리 저장") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/{gameId}/history") public ResponseEntity addHistory( From 537921cfdb4d030cea289d70c7b57d6ba0d4347f Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 3 Oct 2025 03:07:31 +0900 Subject: [PATCH 494/527] refactor-shared-games-uuid --- .../demo/controller/SharedGameController.java | 6 ++---- .../PublicSharedGameDetailResponse.java | 11 ++++++---- .../demo/service/SharedGameService.java | 21 +++++++------------ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 34ffebb3..e37f9643 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -49,14 +49,12 @@ public ResponseEntity share(Authentication authentication, @RequestBody Share */ @Operation(summary = "공유 게임 목록 조회") @GetMapping - public ResponseEntity> getPublicSharedGames(Authentication authentication, - @RequestParam(required = false) UUID lastUUID, + public ResponseEntity> getPublicSharedGames(@RequestParam(required = false) UUID lastUUID, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) List tagIds, @RequestParam(required = false) String query, @RequestParam(defaultValue = "POPULAR")SharedGameSort sort) { - Long viewerId = (authentication == null) ? null : Long.valueOf(authentication.getName()); - return sharedGameService.getPublicSharedGames(viewerId, lastUUID, size, tagIds, query, sort); + return sharedGameService.getPublicSharedGames(lastUUID, size, tagIds, query, sort); } /* diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index f7f22112..fc98e70d 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -10,12 +10,13 @@ @Data public class PublicSharedGameDetailResponse { private UUID sharedGameUUID; - private String nickname; - private String thumbnailUrl; - private Long totalPlayed; + private String posterUrl; private String title; private String worldView; private String backgroundStory; + private String creator; + private Long playCount; + private Long likeCount; @JsonFormat(shape = JsonFormat.Shape.STRING) private LocalDateTime sharedAt; @@ -24,9 +25,11 @@ public class PublicSharedGameDetailResponse { @Data public static class TagDto { + private Long tagId; private String tagName; - public TagDto(String tagName) { + public TagDto(Long tagId, String tagName) { + this.tagId = tagId; this.tagName = tagName; } } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 7f83d016..cfd6581c 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -99,25 +99,26 @@ public ResponseEntity getDetailedSharedGame(UUID uuid) { SharedGame game = sharedGameRepository.findByUuid(uuid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); - List tagName = gameTagRepository.findTagNamesBySharedGameId(game.getId()); + List tagDtos = gameTagRepository.findTagDtosBySharedGameId(game.getId()); List score = sharedGameScoreRepository.findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(game.getId()); PublicSharedGameDetailResponse dto = new PublicSharedGameDetailResponse(); dto.setSharedGameUUID(game.getUuid()); - dto.setNickname(game.getUser().getNickname()); - dto.setThumbnailUrl(game.getThumbnailUrl()); - dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); + dto.setPosterUrl(game.getThumbnailUrl()); dto.setTitle(game.getTitle()); dto.setWorldView(game.getWorldView()); dto.setBackgroundStory(game.getBackgroundStory()); + dto.setCreator(game.getUser().getNickname()); + dto.setPlayCount(sharedGameScoreRepository.countBySharedGameId(game.getId())); + dto.setLikeCount(sharedGameFavoriteRepository.countBySharedGameId(game.getId())); dto.setSharedAt(game.getSharedAt()); List tagarray = new ArrayList<>(); List topscorearray = new ArrayList<>(); - for(var tagNames : tagName) { - tagarray.add(new PublicSharedGameDetailResponse.TagDto(tagNames)); + for(var tagDto : tagDtos) { + tagarray.add(new PublicSharedGameDetailResponse.TagDto(tagDto.getTagId(), tagDto.getTagName())); } dto.setTags(tagarray); @@ -146,7 +147,7 @@ public ResponseEntity getTag() { } @Transactional(readOnly = true) - public ResponseEntity> getPublicSharedGames(Long userId, + public ResponseEntity> getPublicSharedGames( UUID lastUuid, int size, List tagIds, @@ -222,12 +223,6 @@ public ResponseEntity> getPublicSharedGames Long topScore = sharedGameScoreRepository.maxScoreBySharedGameId(g.getId()); dto.setTopScore(topScore == null ? 0L : topScore); - // 좋아요 여부 - if (userId != null) { - boolean liked = sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, g.getId()); - dto.setLiked(liked); - } - // 태그 dto.setTags(gameTagRepository.findTagDtosBySharedGameId(g.getId())); return dto; From e44862bd80d1b5b892c61e1f730549692a42f792 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 3 Oct 2025 03:15:49 +0900 Subject: [PATCH 495/527] refactor-shared-games-uuids --- .../com/scriptopia/demo/controller/SharedGameController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index e37f9643..11689904 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -61,8 +61,8 @@ public ResponseEntity> getPublicSharedGames 게임공유 : 공유된 게임 상세 조회 */ @Operation(summary = "공유 게임 상세 조회") - @GetMapping("/{sharedGameId}") - public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameId") UUID sharedGameId) { + @GetMapping("/{sharedGameUuId}") + public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameUuId") UUID sharedGameId) { return sharedGameService.getDetailedSharedGame(sharedGameId); } From 0f1f4ca2d509c3b27a50e5ecdb44a792db33e71a Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 3 Oct 2025 15:18:47 +0900 Subject: [PATCH 496/527] refactor-shared-game-uuids --- .../demo/controller/SharedGameController.java | 10 +++++----- .../com/scriptopia/demo/dto/sharedgame/CursorPage.java | 2 +- .../demo/repository/SharedGameScoreRepository.java | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 11689904..35a0caf6 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -49,11 +49,11 @@ public ResponseEntity share(Authentication authentication, @RequestBody Share */ @Operation(summary = "공유 게임 목록 조회") @GetMapping - public ResponseEntity> getPublicSharedGames(@RequestParam(required = false) UUID lastUUID, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) List tagIds, - @RequestParam(required = false) String query, - @RequestParam(defaultValue = "POPULAR")SharedGameSort sort) { + public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUUID", required = false) UUID lastUUID, + @RequestParam(value = "size", defaultValue = "20") int size, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "query", required = false) String query, + @RequestParam(value = "sort", defaultValue = "POPULAR") SharedGameSort sort) { return sharedGameService.getPublicSharedGames(lastUUID, size, tagIds, query, sort); } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java index 6e2d32a1..61af4e35 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/CursorPage.java @@ -3,4 +3,4 @@ import java.util.List; import java.util.UUID; -public record CursorPage(List items, UUID nextCursor, boolean hasNext) {} \ No newline at end of file +public record CursorPage(List items, UUID lastUuid, boolean hasNextPage) {} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java index bd759bde..3dd3b415 100644 --- a/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/SharedGameScoreRepository.java @@ -2,15 +2,15 @@ import com.scriptopia.demo.domain.SharedGame; import com.scriptopia.demo.domain.SharedGameScore; -import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface SharedGameScoreRepository extends JpaRepository { @Query("Select count(s) from SharedGameScore s where s.sharedGame.id = :sharedGameId") - long countBySharedGameId(Long sharedGameId); + long countBySharedGameId(@Param("sharedGameId") Long sharedGameId); @Query("select coalesce(max(s.score), 0) from SharedGameScore s where s.sharedGame.id = :sharedGameId") Long maxScoreBySharedGameId(@Param("sharedGameId") Long sharedGameId); From f3c78dab1f325fd2dbac002fc2c4cef988494789 Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:31:54 +0900 Subject: [PATCH 497/527] feat: refactoring controller and service remove not use methods --- .../controller/GameSessionController.java | 25 --- .../demo/controller/UserController.java | 22 +-- .../demo/service/HistoryService.java | 150 ------------------ .../scriptopia/demo/service/UserService.java | 27 ++++ 4 files changed, 31 insertions(+), 193 deletions(-) delete mode 100644 src/main/java/com/scriptopia/demo/service/HistoryService.java diff --git a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java index 1780e326..50c0b01d 100644 --- a/src/main/java/com/scriptopia/demo/controller/GameSessionController.java +++ b/src/main/java/com/scriptopia/demo/controller/GameSessionController.java @@ -5,7 +5,6 @@ import com.scriptopia.demo.dto.gamesession.*; import com.scriptopia.demo.dto.history.HistoryResponse; import com.scriptopia.demo.service.GameSessionService; -import com.scriptopia.demo.service.HistoryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -21,7 +20,6 @@ public class GameSessionController { private final GameSessionService gameSessionService; - private final HistoryService historyService; /* * 게임 -> 게임 도중 종료 @@ -173,29 +171,6 @@ public ResponseEntity sellItem( return ResponseEntity.ok(response); } - /** - * 현재는 userId, sessionId를 통해 저장하는데 - * 인증 관리 부분 끝나면 header에 token 꺼내오고 requestparameter session_id로 저장하게 수정 - */ - /* - * 게임 -> 히스토리 생성 - */ - @Operation(summary = "") - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/history") - public ResponseEntity addHistory(Authentication authentication, @RequestBody GameSessionRequest request) { - Long userId = Long.valueOf(authentication.getName()); - return historyService.createHistory(userId, request.getGameId()); - } - - /** 개발용: 로컬 MongoDB에 더미 세션 한 건 심어서 테스트용 ObjectId 반환 */ - @Operation(summary = "") - @PostMapping("/history/seed") - public ResponseEntity seed(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - return historyService.seedDummySession(userId); - } - /* * 게임 종료 후 -> 히스토리 생성 diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 75bdca5f..f147a945 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -5,7 +5,6 @@ import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; -import com.scriptopia.demo.service.HistoryService; import com.scriptopia.demo.service.UserCharacterImgService; import com.scriptopia.demo.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -16,7 +15,6 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.UUID; @@ -28,7 +26,6 @@ public class UserController { private final UserService userService; - private final HistoryService historyService; private final UserCharacterImgService userCharacterImgService; @Operation(summary = "보유 장비 아이템 조회") @@ -94,36 +91,25 @@ public ResponseEntity> getHistory(@RequestParam(requir Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); - return historyService.fetchMyHistory(userId, lastId, size); + return ResponseEntity.ok(userService.fetchMyHistory(userId, lastId, size)); } @Operation(summary = "프로필 이미지 변경") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/profile-images/url") + @PostMapping("/profile-images") public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { Long userId = Long.valueOf(authentication.getName()); return userCharacterImgService.saveUserCharacterImg(userId, url); } - @Operation(summary = "프로필 등록할 수 있는 이미지 조회") + @Operation(summary = "프로필 이미지 조회") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @GetMapping("/images") + @GetMapping("/profile-images") public ResponseEntity getUserCharacterImgs(Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return userCharacterImgService.getUserCharacterImg(userId); } - /* - 등록할 수 있는 이미지 저장 - */ - @Operation(summary = "등록할 수 있는 이미지 저장") - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("/save/img") - public ResponseEntity saveCharacterImg(Authentication authentication, @RequestParam("file") MultipartFile file) { - Long userId = Long.valueOf(authentication.getName()); - - return userCharacterImgService.saveCharacterImg(userId, file); - } } diff --git a/src/main/java/com/scriptopia/demo/service/HistoryService.java b/src/main/java/com/scriptopia/demo/service/HistoryService.java deleted file mode 100644 index 35459fa9..00000000 --- a/src/main/java/com/scriptopia/demo/service/HistoryService.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.scriptopia.demo.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.scriptopia.demo.domain.History; -import com.scriptopia.demo.domain.User; -import com.scriptopia.demo.dto.history.HistoryPageResponse; -import com.scriptopia.demo.dto.history.HistoryRequest; -import com.scriptopia.demo.exception.CustomException; -import com.scriptopia.demo.exception.ErrorCode; -import com.scriptopia.demo.repository.HistoryRepository; -import com.scriptopia.demo.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.bson.Document; -import org.bson.types.ObjectId; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class HistoryService { - private final UserRepository userRepository; - private final HistoryRepository historyRepository; - private final MongoTemplate mongoTemplate; - private final ObjectMapper objectMapper; - - private static final String COLL = "game_session"; - - @Transactional - public ResponseEntity createHistory(Long userId, String sid) { - ObjectId oid = new ObjectId(sid); - - Query q = Query.query(Criteria.where("_id").is(oid)); - Document doc = mongoTemplate.findOne(q, Document.class, COLL); - if(doc == null) return ResponseEntity.badRequest().body("세션 ID 없음"); - - Object historyIdInSession = doc.get("history_id"); - - if(historyIdInSession != null) { - return ResponseEntity.ok(Map.of("historyId", ((Number)historyIdInSession).longValue())); - } - - HistoryRequest req = mapMongoToHistoryRequest(doc); - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - History history = new History(user, req); - - return ResponseEntity.ok(historyRepository.save(history)); - } - - @Transactional - public ResponseEntity seedDummySession(Long userId) { - Document hi = new Document(Map.ofEntries( - Map.entry("title", "임시 여정 제목"), - Map.entry("world_prompt", "임시 세계관 프롬프트"), - Map.entry("background_story", "AI가 생성한 배경 이야기"), - Map.entry("world_view", "AI가 생성한 세계관"), - Map.entry("epilogue_1_title", "엔딩A"), - Map.entry("epilogue_1_content", "엔딩A 내용"), - Map.entry("epilogue_2_title", "엔딩B"), - Map.entry("epilogue_2_content", "엔딩B 내용"), - Map.entry("epilogue_3_title", "엔딩C"), - Map.entry("epilogue_3_content", "엔딩C 내용"), - Map.entry("score", 1234) - )); - - Document doc = new Document(); - doc.put("user_id", userId); - doc.put("scene_type", "done"); - doc.put("started_at", Instant.now()); - doc.put("updated_at", Instant.now()); - doc.put("background", "https://cdn.example.com/bg/temp.png"); // 썸네일로 매핑할 예정 - doc.put("progress", 100); - doc.put("stage", List.of(1,2,3)); - doc.put("history_info", hi); - - Document saved = mongoTemplate.insert(doc, COLL); - return ResponseEntity.ok(saved.getObjectId("_id").toHexString()); - } - - private HistoryRequest mapMongoToHistoryRequest(Document doc) { - JsonNode root = asJson(doc); - JsonNode hi = root.path("history_info"); - - // 필수값: title, world_prompt, score - String title = hi.path("title").asText(""); - String worldPrompt = hi.path("world_prompt").asText(""); - Integer score = hi.path("score").isNumber() ? hi.path("score").asInt() : null; - if (title.isBlank() || worldPrompt.isBlank() || score == null) { - throw new IllegalArgumentException("history_info의 필수값(title, world_prompt, score)이 누락되었습니다."); - } - - HistoryRequest req = new HistoryRequest(); - // thumbnailUrl: Mongo의 background를 임시 썸네일로 사용 - req.setThumbnailUrl(root.path("background").isTextual() ? root.get("background").asText() : null); - - req.setTitle(title); - // 정책에 맞게 매핑: worldView는 비워두거나 world_prompt로 대체 가능 - req.setBackgroundStory(hi.path("background_story").asText(null)); - req.setWorldView(hi.path("world_view").asText(null)); // 또는 req.setWorldView(worldPrompt); - req.setWorldPrompt(worldPrompt); - - req.setEpilogue1Title(hi.path("epilogue_1_title").asText(null)); - req.setEpilogue1Content(hi.path("epilogue_1_content").asText(null)); - req.setEpilogue2Title(hi.path("epilogue_2_title").asText(null)); - req.setEpilogue2Content(hi.path("epilogue_2_content").asText(null)); - req.setEpilogue3Title(hi.path("epilogue_3_title").asText(null)); - req.setEpilogue3Content(hi.path("epilogue_3_content").asText(null)); - - req.setScore(score.longValue()); - return req; - } - - private JsonNode asJson(Document doc) { - try { return objectMapper.readTree(doc.toJson()); } - catch (Exception e) { throw new RuntimeException("Mongo Document → JsonNode 변환 실패", e); } - } - - @Transactional(readOnly = true) - public ResponseEntity> fetchMyHistory(Long userId, UUID lastId, int size) { - PageRequest pr = PageRequest.of(0, size); - Page page; - - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(user.getId(), pr); - else { - Long lastIds = historyRepository.findByUserIdAndUuid(user.getId(), lastId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_PAGE_NOT_FOUND)); - - page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(user.getId(), lastIds, pr); - } - - return ResponseEntity.ok(page.getContent().stream().map(HistoryPageResponse::from).toList()); - } - -} \ No newline at end of file diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index f729030a..df08bfbd 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -1,8 +1,10 @@ package com.scriptopia.demo.service; +import com.scriptopia.demo.domain.History; import com.scriptopia.demo.domain.User; import com.scriptopia.demo.domain.UserPiaItem; import com.scriptopia.demo.domain.UserSetting; +import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; @@ -10,22 +12,28 @@ import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.mapper.ItemMapper; +import com.scriptopia.demo.repository.HistoryRepository; import com.scriptopia.demo.repository.UserPiaItemRepository; import com.scriptopia.demo.repository.UserRepository; import com.scriptopia.demo.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserService { + private final HistoryRepository historyRepository; private final UserSettingRepository userSettingRepository; private final UserRepository userRepository; private final UserPiaItemRepository userPiaItemRepository; @@ -105,6 +113,25 @@ public List getPiaItems(String userId){ return piaItems; } + @Transactional(readOnly = true) + public List fetchMyHistory(Long userId, UUID lastId, int size) { + PageRequest pr = PageRequest.of(0, size); + Page page; + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + + if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(user.getId(), pr); + else { + Long lastIds = historyRepository.findByUserIdAndUuid(user.getId(), lastId) + .orElseThrow(() -> new CustomException(ErrorCode.E_404_PAGE_NOT_FOUND)); + + page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(user.getId(), lastIds, pr); + } + + return page.getContent().stream().map(HistoryPageResponse::from).toList(); + } + } From dd845cd0a38356cc83ccd19aba2bf407a6d10aae Mon Sep 17 00:00:00 2001 From: junseo Lee <74139368+juns0720@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:45:14 +0900 Subject: [PATCH 498/527] Delete docker_compose_files/.env --- docker_compose_files/.env | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 docker_compose_files/.env diff --git a/docker_compose_files/.env b/docker_compose_files/.env deleted file mode 100644 index 27c0c4e4..00000000 --- a/docker_compose_files/.env +++ /dev/null @@ -1,8 +0,0 @@ -# Postgres -POSTGRES_USER=root -POSTGRES_PASSWORD=tiger -POSTGRES_DB=scriptopia - -# Mongo -MONGO_INITDB_ROOT_USERNAME=root -MONGO_INITDB_ROOT_PASSWORD=tiger From 6706f38e3c4889e9d69833ccdf4012e73ec0b86f Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 3 Oct 2025 18:03:29 +0900 Subject: [PATCH 499/527] refactor-usercontroller-profile-image --- .gitignore | 1 + .../scriptopia/demo/controller/UserController.java | 5 +++-- .../scriptopia/demo/dto/users/UserImageRequest.java | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java diff --git a/.gitignore b/.gitignore index 5410b950..cfc2e859 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ out/ ### VS Code ### .vscode/ +/docker_compose_file/.env /docker_compose_files/data /docker_compose_files/mongo_data /docker_compose_files/postgres_data diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index f147a945..295c43bc 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -4,6 +4,7 @@ import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; +import com.scriptopia.demo.dto.users.UserImageRequest; import com.scriptopia.demo.dto.users.UserSettingsDTO; import com.scriptopia.demo.service.UserCharacterImgService; import com.scriptopia.demo.service.UserService; @@ -97,10 +98,10 @@ public ResponseEntity> getHistory(@RequestParam(requir @Operation(summary = "프로필 이미지 변경") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") @PostMapping("/profile-images") - public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestParam("url") String url) { + public ResponseEntity saveUserCharacterImg(Authentication authentication, @RequestBody UserImageRequest req) { Long userId = Long.valueOf(authentication.getName()); - return userCharacterImgService.saveUserCharacterImg(userId, url); + return userCharacterImgService.saveUserCharacterImg(userId, req.getUrl()); } @Operation(summary = "프로필 이미지 조회") diff --git a/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java b/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java new file mode 100644 index 00000000..d22faa45 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto.users; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +@Data +@NoArgsConstructor +@RequiredArgsConstructor +public class UserImageRequest { + private String url; +} From 8784078357ea7fada9a7d2293282cdfc406840c2 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Fri, 3 Oct 2025 18:11:12 +0900 Subject: [PATCH 500/527] refactor-gamesession-Uuid --- .../java/com/scriptopia/demo/dto/history/HistoryResponse.java | 3 ++- .../java/com/scriptopia/demo/service/GameSessionService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java index cf794a39..08295073 100644 --- a/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryResponse.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.UUID; @Builder @@ -13,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class HistoryResponse { - private Long id; + private UUID uuid; private Long userId; private String thumbnailUrl; private String title; diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index fe6ef215..d7f95746 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1306,7 +1306,7 @@ public ResponseEntity gameToEnd(Long userId) { HistoryResponse historyResponse = HistoryResponse.builder() - .id(history.getId()) + .uuid(history.getUuid()) .userId(user.getId()) .thumbnailUrl(history.getThumbnailUrl()) .title(history.getTitle()) From 9c4c9dd9c1a1630d3bc42d5042b203f0d5350322 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 3 Oct 2025 18:25:29 +0900 Subject: [PATCH 501/527] =?UTF-8?q?=C3=A3update=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5410b950..7f0d5f34 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ out/ ### VS Code ### .vscode/ +/docker_compose_files/.env /docker_compose_files/data /docker_compose_files/mongo_data /docker_compose_files/postgres_data From 49548e386f0a001244d91e1296e3a9555cfff9c9 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sat, 4 Oct 2025 17:11:15 +0900 Subject: [PATCH 502/527] refactor-gameHistory --- .../demo/controller/UserController.java | 7 ++-- .../demo/dto/history/HistoryPageResponse.java | 41 +++++++++++++++---- .../dto/history/HistoryPageResponseDto.java | 15 +++++++ .../demo/dto/users/UserImageRequest.java | 2 - .../demo/repository/HistoryRepository.java | 18 +++++--- .../demo/service/SharedGameService.java | 2 + .../scriptopia/demo/service/UserService.java | 31 +++++++------- 7 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponseDto.java diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 295c43bc..471fab1f 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.controller; import com.scriptopia.demo.dto.history.HistoryPageResponse; +import com.scriptopia.demo.dto.history.HistoryPageResponseDto; import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; @@ -87,9 +88,9 @@ public ResponseEntity getUserAssets( @Operation(summary = "사용자 게임 기록 조회") @GetMapping("/games/histories") - public ResponseEntity> getHistory(@RequestParam(required = false) UUID lastId, - @RequestParam(defaultValue = "10") int size, - Authentication authentication) { + public ResponseEntity getHistory(@RequestParam(required = false) UUID lastId, + @RequestParam(defaultValue = "10") int size, + Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return ResponseEntity.ok(userService.fetchMyHistory(userId, lastId, size)); diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java index 922a7535..5489df9f 100644 --- a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponse.java @@ -1,27 +1,50 @@ package com.scriptopia.demo.dto.history; import com.scriptopia.demo.domain.History; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.UUID; @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class HistoryPageResponse { private UUID uuid; + private String thumbnailUrl; private String title; + private String worldView; + private String backgroundStory; + private String epilogue1Title; + private String epilogue1Content; + private String epilogue2Title; + private String epilogue2Content; + private String epilogue3Title; + private String epilogue3Content; private Long score; - private String thumbnail_url; private LocalDateTime created_at; + private boolean isShared; public static HistoryPageResponse from(History h) { - HistoryPageResponse dto = new HistoryPageResponse(); - dto.setUuid(h.getUuid()); - dto.setTitle(h.getTitle()); - dto.setScore(h.getScore()); - dto.setThumbnail_url(h.getThumbnailUrl()); - dto.setCreated_at(h.getCreatedAt()); - - return dto; + return HistoryPageResponse.builder() + .uuid(h.getUuid()) + .thumbnailUrl(h.getThumbnailUrl()) + .title(h.getTitle()) + .worldView(h.getWorldView()) + .backgroundStory(h.getBackgroundStory()) + .epilogue1Title(h.getEpilogue1Title()) + .epilogue1Content(h.getEpilogue1Content()) + .epilogue2Title(h.getEpilogue2Title()) + .epilogue2Content(h.getEpilogue2Content()) + .epilogue3Title(h.getEpilogue3Title()) + .epilogue3Content(h.getEpilogue3Content()) + .score(h.getScore()) + .created_at(h.getCreatedAt()) + .isShared(h.getIsShared()) + .build(); } } diff --git a/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponseDto.java b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponseDto.java new file mode 100644 index 00000000..8111e74e --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/history/HistoryPageResponseDto.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.history; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; +import java.util.UUID; + +@Data +@AllArgsConstructor +public class HistoryPageResponseDto { + private List data; + private UUID nextCursor; + private boolean hasNext; +} diff --git a/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java b/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java index d22faa45..f00f9cbd 100644 --- a/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/users/UserImageRequest.java @@ -2,11 +2,9 @@ import lombok.Data; import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; @Data @NoArgsConstructor -@RequiredArgsConstructor public class UserImageRequest { private String url; } diff --git a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java index 245f0217..ba430950 100644 --- a/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java +++ b/src/main/java/com/scriptopia/demo/repository/HistoryRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,10 +15,15 @@ public interface HistoryRepository extends JpaRepository { @Query("select h from History h where h.uuid = :uuid") Optional findByUuid(@Param("uuid") UUID uuid); - @Query("select h.id from History h where h.user.id = :userId and h.uuid = :uuid") - Optional findByUserIdAndUuid(@Param("userId") Long userId, @Param("uuid") UUID uuid); - - Page findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long lastId, Pageable pageable); - - Page findByUserIdOrderByIdDesc(Long userId, Pageable pageable); + @Query(""" + SELECT h FROM History h + WHERE h.user.id = :userId + AND (:cursor IS NULL OR h.createdAt < (SELECT h2.createdAt FROM History h2 WHERE h2.uuid = :cursor)) + ORDER BY h.createdAt DESC + """) + List findHistoriesByUserWithCursor( + @Param("userId") Long userId, + @Param("cursor") UUID cursor, + Pageable pageable + ); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index cfd6581c..856c97db 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -41,6 +41,8 @@ public ResponseEntity saveSharedGame(Long Id, UUID uuid) { throw new CustomException(ErrorCode.E_401_NOT_EQUAL_SHARED_GAME); } + history.setIsShared(true); + SharedGame sharedGame = SharedGame.from(user, history); return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); } diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index df08bfbd..2667cfe6 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -5,6 +5,8 @@ import com.scriptopia.demo.domain.UserPiaItem; import com.scriptopia.demo.domain.UserSetting; import com.scriptopia.demo.dto.history.HistoryPageResponse; +import com.scriptopia.demo.dto.history.HistoryPageResponseDto; +import com.scriptopia.demo.dto.history.HistoryResponse; import com.scriptopia.demo.dto.items.ItemDTO; import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; @@ -27,6 +29,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -114,24 +117,22 @@ public List getPiaItems(String userId){ } @Transactional(readOnly = true) - public List fetchMyHistory(Long userId, UUID lastId, int size) { - PageRequest pr = PageRequest.of(0, size); - Page page; + public HistoryPageResponseDto fetchMyHistory(Long userId, UUID lastId, int size) { + List histories = historyRepository.findHistoriesByUserWithCursor( + userId, + lastId, + PageRequest.of(0, size + 1) + ); - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); + boolean hasNext = histories.size() > size; - if(lastId == null) page = historyRepository.findByUserIdOrderByIdDesc(user.getId(), pr); - else { - Long lastIds = historyRepository.findByUserIdAndUuid(user.getId(), lastId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_PAGE_NOT_FOUND)); + List result = histories.stream() + .limit(size) + .map(HistoryPageResponse::from) + .collect(Collectors.toList()); - page = historyRepository.findByUserIdAndIdLessThanOrderByIdDesc(user.getId(), lastIds, pr); - } + UUID nextCursor = hasNext ? result.get(result.size() - 1).getUuid() : null; - return page.getContent().stream().map(HistoryPageResponse::from).toList(); + return new HistoryPageResponseDto(result, nextCursor, hasNext); } - - - } From dfc77b72c8db6ed51aa26acaddf59cc99c653175 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 5 Oct 2025 15:14:26 +0900 Subject: [PATCH 503/527] refactor-gameHistory, gameShared --- .../demo/controller/SharedGameController.java | 20 ++++++++++++----- .../demo/dto/TagDef/TagDefDeleteRequest.java | 2 +- .../PublicSharedGameDetailResponse.java | 4 +++- .../dto/sharedgame/SharedGameSaveDto.java | 21 ++++++++++++++++++ .../SharedGameFavoriteResponse.java | 13 +++++++++-- .../service/SharedGameFavoriteService.java | 21 +++++++++--------- .../demo/service/SharedGameService.java | 22 ++++++++++++++++--- .../demo/service/TagDefService.java | 2 +- 8 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameSaveDto.java diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 35a0caf6..ac78a53a 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -16,7 +16,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -62,8 +64,16 @@ public ResponseEntity> getPublicSharedGames */ @Operation(summary = "공유 게임 상세 조회") @GetMapping("/{sharedGameUuId}") - public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameUuId") UUID sharedGameId) { - return sharedGameService.getDetailedSharedGame(sharedGameId); + public ResponseEntity getSharedGameDetail(Authentication authentication, @PathVariable("sharedGameUuId") UUID sharedGameId) { + Long userId = null; + if (authentication != null && authentication.isAuthenticated() && authentication.getName() != null) { + try { + userId = Long.valueOf(authentication.getName()); + } catch (NumberFormatException ignored) { + } + } + + return sharedGameService.getDetailedSharedGame(userId, sharedGameId); } /* @@ -71,8 +81,8 @@ public ResponseEntity getSharedGameDetail(@PathVariable("sharedGameUuId") UUI */ @Operation(summary = "공유 게임 Like 요청") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("{sharedGameId}/like") - public ResponseEntity likeSharedGame(@PathVariable("sharedGameId") UUID sharedGameId, Authentication authentication) { + @PostMapping("{sharedGameUuId}/like") + public ResponseEntity likeSharedGame(@PathVariable("sharedGameUuId") UUID sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); @@ -92,7 +102,7 @@ public ResponseEntity getSharedGameTags() { */ @Operation(summary = "공유한 게임 삭제") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @DeleteMapping("/shared-games") + @DeleteMapping public ResponseEntity delete(Authentication authentication, @RequestBody SharedGameRequest req) { Long userId = Long.valueOf(authentication.getName()); diff --git a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java index d0996d03..6573c474 100644 --- a/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/TagDef/TagDefDeleteRequest.java @@ -8,5 +8,5 @@ @AllArgsConstructor @NoArgsConstructor public class TagDefDeleteRequest { - private String name; + private String tagName; } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index fc98e70d..d84bdaf3 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -9,7 +9,7 @@ @Data public class PublicSharedGameDetailResponse { - private UUID sharedGameUUID; + private UUID sharedGameUuID; private String posterUrl; private String title; private String worldView; @@ -17,6 +17,7 @@ public class PublicSharedGameDetailResponse { private String creator; private Long playCount; private Long likeCount; + private boolean isLiked; @JsonFormat(shape = JsonFormat.Shape.STRING) private LocalDateTime sharedAt; @@ -37,6 +38,7 @@ public TagDto(Long tagId, String tagName) { @Data public static class TopScoreDto { private String nickname; + private String profileUrl; private Long score; @JsonFormat(shape = JsonFormat.Shape.STRING) private LocalDateTime createdAt; diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameSaveDto.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameSaveDto.java new file mode 100644 index 00000000..0b1dd6f7 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/SharedGameSaveDto.java @@ -0,0 +1,21 @@ +package com.scriptopia.demo.dto.sharedgame; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SharedGameSaveDto { + private String sharedGameUuid; + private String thumbnailUrl; + private Long recommand; + private Long playCount; + private String title; + private String worldView; + private String backgroundStory; + private LocalDateTime sharedAt; +} diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java index ff091570..e3dca084 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java @@ -1,15 +1,24 @@ package com.scriptopia.demo.dto.sharedgamefavorite; +import com.scriptopia.demo.dto.sharedgame.TagDto; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; @Data +@AllArgsConstructor +@NoArgsConstructor +@Builder public class SharedGameFavoriteResponse { - private Long sharedGameId; + private String sharedGameUuid; private String thumbnailUrl; private boolean isLiked; private Long likeCount; private Long totalPlayCount; private String title; - private String[] tags; + private List tags; private Long topScore; } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java index 038a9d86..3a10bdb5 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameFavoriteService.java @@ -48,17 +48,18 @@ public ResponseEntity saveFavorite(Long userId, UUID uuid) { Long maxScore = sharedGameScoreRepository.maxScoreBySharedGameId(game.getId()); // 태그 이름들 - var tagNames = gameTagRepository.findTagNamesBySharedGameId(game.getId()); + var tags = gameTagRepository.findTagDtosBySharedGameId(game.getId()); - var dto = new SharedGameFavoriteResponse(); - dto.setSharedGameId(game.getId()); - dto.setThumbnailUrl(game.getThumbnailUrl()); - dto.setLiked(liked); - dto.setLikeCount(likeCount); - dto.setTotalPlayCount(playCount); - dto.setTitle(game.getTitle()); - dto.setTags(tagNames.isEmpty() ? null : tagNames.toArray(new String[0])); - dto.setTopScore(maxScore); + var dto = SharedGameFavoriteResponse.builder() + .sharedGameUuid(game.getUuid().toString()) + .thumbnailUrl(game.getThumbnailUrl()) + .isLiked(liked) + .likeCount(likeCount) + .totalPlayCount(playCount) + .title(game.getTitle()) + .tags(tags) + .topScore(maxScore) + .build(); return ResponseEntity.ok(dto); } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 856c97db..7881abe2 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,7 +45,19 @@ public ResponseEntity saveSharedGame(Long Id, UUID uuid) { history.setIsShared(true); SharedGame sharedGame = SharedGame.from(user, history); - return ResponseEntity.ok(sharedGameRepository.save(sharedGame)); + sharedGameRepository.save(sharedGame); + + SharedGameSaveDto dto = new SharedGameSaveDto(); + dto.setSharedGameUuid(sharedGame.getUuid().toString()); + dto.setThumbnailUrl(sharedGame.getThumbnailUrl()); + dto.setRecommand(sharedGameFavoriteRepository.countBySharedGameId(sharedGame.getId())); + dto.setPlayCount(sharedGameScoreRepository.countBySharedGameId(sharedGame.getId())); + dto.setTitle(sharedGame.getTitle()); + dto.setWorldView(sharedGame.getWorldView()); + dto.setBackgroundStory(sharedGame.getBackgroundStory()); + dto.setSharedAt(sharedGame.getSharedAt()); + + return ResponseEntity.ok(dto); } public ResponseEntity getMySharedGames(Long userId) { @@ -97,16 +110,17 @@ public void deleteSharedGame(Long id, UUID uuid) { sharedGameRepository.delete(game); } - public ResponseEntity getDetailedSharedGame(UUID uuid) { + public ResponseEntity getDetailedSharedGame(Long userId, UUID uuid) { SharedGame game = sharedGameRepository.findByUuid(uuid) .orElseThrow(() -> new CustomException(ErrorCode.E_404_SHARED_GAME_NOT_FOUND)); List tagDtos = gameTagRepository.findTagDtosBySharedGameId(game.getId()); List score = sharedGameScoreRepository.findAllBySharedGameIdOrderByScoreDescCreatedAtDesc(game.getId()); + boolean isLiked = (userId != null) && sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, game.getId()); PublicSharedGameDetailResponse dto = new PublicSharedGameDetailResponse(); - dto.setSharedGameUUID(game.getUuid()); + dto.setSharedGameUuID(game.getUuid()); dto.setPosterUrl(game.getThumbnailUrl()); dto.setTitle(game.getTitle()); dto.setWorldView(game.getWorldView()); @@ -115,6 +129,7 @@ public ResponseEntity getDetailedSharedGame(UUID uuid) { dto.setPlayCount(sharedGameScoreRepository.countBySharedGameId(game.getId())); dto.setLikeCount(sharedGameFavoriteRepository.countBySharedGameId(game.getId())); dto.setSharedAt(game.getSharedAt()); + dto.setLiked(isLiked); List tagarray = new ArrayList<>(); List topscorearray = new ArrayList<>(); @@ -129,6 +144,7 @@ public ResponseEntity getDetailedSharedGame(UUID uuid) { PublicSharedGameDetailResponse.TopScoreDto topscore = new PublicSharedGameDetailResponse.TopScoreDto(); topscore.setNickname(topScoreInfo.getUser().getNickname()); topscore.setScore(topScoreInfo.getScore()); + topscore.setProfileUrl(topScoreInfo.getUser().getProfileImgUrl()); topscore.setCreatedAt(topScoreInfo.getCreatedAt()); topscorearray.add(topscore); } diff --git a/src/main/java/com/scriptopia/demo/service/TagDefService.java b/src/main/java/com/scriptopia/demo/service/TagDefService.java index 4f820349..b0045339 100644 --- a/src/main/java/com/scriptopia/demo/service/TagDefService.java +++ b/src/main/java/com/scriptopia/demo/service/TagDefService.java @@ -35,7 +35,7 @@ public ResponseEntity addTagName(TagDefCreateRequest req) { @Transactional public ResponseEntity removeTagName(TagDefDeleteRequest req) { - TagDef tag = tagDefRepository.findByTagName(req.getName()) + TagDef tag = tagDefRepository.findByTagName(req.getTagName()) .orElseThrow(() -> new CustomException(ErrorCode.E_404_Tag_NOT_FOUND)); tagDefRepository.delete(tag); From cd711630686851ffae337269d38a72c2fbcdea38 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 5 Oct 2025 15:55:15 +0900 Subject: [PATCH 504/527] refactor-gameShared DTO --- .../demo/controller/SharedGameController.java | 12 ++++++------ .../demo/dto/sharedgame/PublicTagDefResponse.java | 2 +- .../SharedGameFavoriteResponse.java | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index ac78a53a..a2251020 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -39,11 +39,11 @@ public class SharedGameController { @Operation(summary = "게임 공유하기") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping - public ResponseEntity share(Authentication authentication, @RequestBody SharedGameRequest req) { + @PostMapping("/{sharedGameUuid}") + public ResponseEntity share(Authentication authentication, @PathVariable("sharedGameUuid") UUID sharedGameUuid) { Long userId = Long.valueOf(authentication.getName()); - return sharedGameService.saveSharedGame(userId, req.getUuid()); + return sharedGameService.saveSharedGame(userId, sharedGameUuid); } /* @@ -102,11 +102,11 @@ public ResponseEntity getSharedGameTags() { */ @Operation(summary = "공유한 게임 삭제") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @DeleteMapping - public ResponseEntity delete(Authentication authentication, @RequestBody SharedGameRequest req) { + @DeleteMapping("/{sharedGameUuid}") + public ResponseEntity delete(Authentication authentication, @PathVariable("sharedGameUuid") UUID sharedGameUuid) { Long userId = Long.valueOf(authentication.getName()); - sharedGameService.deleteSharedGame(userId, req.getUuid()); + sharedGameService.deleteSharedGame(userId, sharedGameUuid); return ResponseEntity.ok("게임이 삭제되었습니다."); } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java index 1a35d3a2..7f5e3668 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicTagDefResponse.java @@ -6,6 +6,6 @@ @Data @AllArgsConstructor public class PublicTagDefResponse { - private Long id; + private Long tagId; private String tagName; } diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java index e3dca084..093574da 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgamefavorite/SharedGameFavoriteResponse.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.sharedgamefavorite; +import com.fasterxml.jackson.annotation.JsonProperty; import com.scriptopia.demo.dto.sharedgame.TagDto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,7 +16,10 @@ public class SharedGameFavoriteResponse { private String sharedGameUuid; private String thumbnailUrl; + + @JsonProperty("isLiked") private boolean isLiked; + private Long likeCount; private Long totalPlayCount; private String title; From 68e565989eadfb23b85f98a36a5bf6d766a2cba5 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 5 Oct 2025 17:14:53 +0900 Subject: [PATCH 505/527] refactor/shared-game --- .../scriptopia/demo/config/JwtAuthFilter.java | 6 ++++- .../demo/config/SecurityConfig.java | 9 ++++++- .../demo/config/SecurityWhitelist.java | 9 +++---- .../demo/controller/SharedGameController.java | 24 ++++--------------- .../sharedgame/PublicSharedGameResponse.java | 2 +- .../exception/GlobalExceptionHandler.java | 14 +++++------ .../demo/service/SharedGameService.java | 6 ++--- src/main/resources/application.yml | 7 +++--- 8 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java index 6a09ac6f..8d03d84f 100644 --- a/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java +++ b/src/main/java/com/scriptopia/demo/config/JwtAuthFilter.java @@ -49,7 +49,11 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce Arrays.stream(SecurityWhitelist.PUBLIC_GETS) .anyMatch(pattern -> pathMatcher.match(pattern, path)); - return authMatch || publicGetMatch; + boolean publicSharedGameUuidGet = "GET".equalsIgnoreCase(method) && + path.matches("^/shared-games/[0-9a-fA-F\\-]{36}$"); + + return authMatch || publicGetMatch || publicSharedGameUuidGet; + } diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 5f7cff64..873e344a 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -20,8 +20,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import java.util.Arrays; @@ -40,7 +42,11 @@ public PasswordEncoder passwordEncoder() { private final JwtAuthFilter jwtAuthFilter; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { + + MvcRequestMatcher publicSharedGameUuidGet = + new MvcRequestMatcher(introspector, "/shared-games/{uuid:[0-9a-fA-F\\-]{36}}"); + publicSharedGameUuidGet.setMethod(HttpMethod.GET); http @@ -52,6 +58,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(SecurityWhitelist.AUTH_WHITELIST).permitAll() //public 권한(GET 요청) .requestMatchers(HttpMethod.GET,SecurityWhitelist.PUBLIC_GETS).permitAll() + .requestMatchers(publicSharedGameUuidGet).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index a1f0140e..005ff9e6 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -18,15 +18,12 @@ public class SecurityWhitelist { "/v3/api-docs/**", "/swagger-ui/**", - "/shops/pia/items" - - - - + "/shops/pia/items", }; public static final String[] PUBLIC_GETS = { "/trades", - "/shared-games/**" + "/shared-games", + "/shared-games/tags" }; } diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index a2251020..934664ce 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -1,13 +1,10 @@ package com.scriptopia.demo.controller; -import com.scriptopia.demo.domain.SharedGame; -import com.scriptopia.demo.domain.SharedGameFavorite; import com.scriptopia.demo.domain.SharedGameSort; import com.scriptopia.demo.dto.TagDef.TagDefCreateRequest; import com.scriptopia.demo.dto.TagDef.TagDefDeleteRequest; import com.scriptopia.demo.dto.sharedgame.CursorPage; import com.scriptopia.demo.dto.sharedgame.PublicSharedGameResponse; -import com.scriptopia.demo.dto.sharedgame.SharedGameRequest; import com.scriptopia.demo.service.SharedGameFavoriteService; import com.scriptopia.demo.service.SharedGameService; import com.scriptopia.demo.service.TagDefService; @@ -16,9 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.authorization.AuthenticatedAuthorizationManager; import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -63,8 +58,8 @@ public ResponseEntity> getPublicSharedGames 게임공유 : 공유된 게임 상세 조회 */ @Operation(summary = "공유 게임 상세 조회") - @GetMapping("/{sharedGameUuId}") - public ResponseEntity getSharedGameDetail(Authentication authentication, @PathVariable("sharedGameUuId") UUID sharedGameId) { + @GetMapping("/{sharedGameUuid}") + public ResponseEntity getSharedGameDetail(Authentication authentication, @PathVariable("sharedGameUuid") UUID sharedGameUuid) { Long userId = null; if (authentication != null && authentication.isAuthenticated() && authentication.getName() != null) { try { @@ -72,8 +67,9 @@ public ResponseEntity getSharedGameDetail(Authentication authentication, @Pat } catch (NumberFormatException ignored) { } } + System.out.println(userId); - return sharedGameService.getDetailedSharedGame(userId, sharedGameId); + return sharedGameService.getDetailedSharedGame(userId, sharedGameUuid); } /* @@ -111,18 +107,6 @@ public ResponseEntity delete(Authentication authentication, @PathVariable("sh return ResponseEntity.ok("게임이 삭제되었습니다."); } - /* - 게임 공유 -> 공유한 게임 조회(내가 공유한 게임 조회) - */ - @Operation(summary = "(내가)공유한 게임 조회") - @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @GetMapping("/me") - public ResponseEntity getMySharedGames(Authentication authentication) { - Long userId = Long.valueOf(authentication.getName()); - - return sharedGameService.getMySharedGames(userId); - } - @Operation(summary = "게임 태그 생성") @PreAuthorize("hasAnyAuthority('ADMIN')") @PostMapping("/tags") diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java index c7a70fdd..e0a1e1bb 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java @@ -8,7 +8,7 @@ @Data public class PublicSharedGameResponse { - private UUID sharedGameId; + private UUID sharedGameUuid; private String thumbnailUrl; private boolean isLiked; private Long likeCount; diff --git a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java index e4d38d15..ed422c1d 100644 --- a/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/scriptopia/demo/exception/GlobalExceptionHandler.java @@ -63,11 +63,11 @@ public ResponseEntity handleExpired(ExpiredJwtException e) { } -// @ExceptionHandler(Exception.class) -// public ResponseEntity handleGeneralException(Exception ex) { -// -// return ResponseEntity -// .status(HttpStatus.INTERNAL_SERVER_ERROR) -// .body(new ErrorResponse(ErrorCode.E_500)); -// } + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(ErrorCode.E_500)); + } } diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 7881abe2..3202a15b 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -6,11 +6,9 @@ import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.repository.*; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -229,7 +227,7 @@ public ResponseEntity> getPublicSharedGames // 5) DTO 매핑 (집계 일원화) List items = rows.stream().map(g -> { PublicSharedGameResponse dto = new PublicSharedGameResponse(); - dto.setSharedGameId(g.getUuid()); + dto.setSharedGameUuid(g.getUuid()); dto.setThumbnailUrl(g.getThumbnailUrl()); dto.setTitle(g.getTitle()); dto.setSharedAt(g.getSharedAt()); @@ -247,7 +245,7 @@ public ResponseEntity> getPublicSharedGames }).toList(); // 6) 커서/hasNext - UUID nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getSharedGameId(); + UUID nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getSharedGameUuid(); boolean hasNext = rows.size() == Math.max(1, size); return ResponseEntity.ok(new CursorPage<>(items, nextCursor, hasNext)); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0f1419c7..b9a312ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,8 +57,6 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} scope: name email profile_image - - auth: jwt: issuer: scriptopia @@ -81,4 +79,7 @@ springdoc: path: /swagger-ui image-dir: ./uploads/ -image-url-prefix: /images \ No newline at end of file +image-url-prefix: /images +logging: + level: + org.springframework.security: DEBUG \ No newline at end of file From 8d06d3d2ccbab0be93410f16910e4f4668f53159 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 5 Oct 2025 17:20:39 +0900 Subject: [PATCH 506/527] refactor-gameshared edit --- .../demo/controller/SharedGameController.java | 4 +- .../PublicSharedGameDetailResponse.java | 3 ++ .../sharedgame/PublicSharedGameResponse.java | 6 +-- .../demo/service/SharedGameService.java | 42 +------------------ 4 files changed, 7 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 934664ce..dd867480 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -77,8 +77,8 @@ public ResponseEntity getSharedGameDetail(Authentication authentication, @Pat */ @Operation(summary = "공유 게임 Like 요청") @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") - @PostMapping("{sharedGameUuId}/like") - public ResponseEntity likeSharedGame(@PathVariable("sharedGameUuId") UUID sharedGameId, Authentication authentication) { + @PostMapping("{sharedGameUuid}/like") + public ResponseEntity likeSharedGame(@PathVariable("sharedGameUuid") UUID sharedGameId, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); return sharedGameFavoriteService.saveFavorite(userId, sharedGameId); diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index d84bdaf3..32dccbcb 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -1,6 +1,7 @@ package com.scriptopia.demo.dto.sharedgame; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.time.LocalDateTime; @@ -17,6 +18,8 @@ public class PublicSharedGameDetailResponse { private String creator; private Long playCount; private Long likeCount; + + @JsonProperty("isLiked") private boolean isLiked; @JsonFormat(shape = JsonFormat.Shape.STRING) diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java index e0a1e1bb..c23a9f06 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameResponse.java @@ -10,12 +10,8 @@ public class PublicSharedGameResponse { private UUID sharedGameUuid; private String thumbnailUrl; - private boolean isLiked; - private Long likeCount; - private Long totalPlayCount; private String title; - private Long topScore; - private LocalDateTime sharedAt; + private Long playCount; private List tags; diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index 3202a15b..bc2a71fe 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -58,41 +58,6 @@ public ResponseEntity saveSharedGame(Long Id, UUID uuid) { return ResponseEntity.ok(dto); } - public ResponseEntity getMySharedGames(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND)); - - List games = sharedGameRepository.findAllByUserid(user.getId()); - - List dtos = new ArrayList<>(); - - for(SharedGame game : games) { - MySharedGameResponse dto = new MySharedGameResponse(); - dto.setShared_game_uuid(game.getUuid()); - dto.setThumbnailUrl(game.getThumbnailUrl()); - dto.setTotalPlayed(sharedGameScoreRepository.countBySharedGameId(game.getId())); - dto.setTitle(game.getTitle()); - dto.setWorldView(game.getWorldView()); - dto.setSharedAt(game.getSharedAt()); - dto.setBackgroundStory(game.getBackgroundStory()); - - boolean liked = sharedGameFavoriteRepository.existsLikeSharedGame(user.getId(), game.getId()); - dto.setRecommand(liked); - - List tagdto = gameTagRepository.findTagNamesBySharedGameId(game.getId()); - List tags = new ArrayList<>(); - - for(String tagName : tagdto) { - tags.add(new MySharedGameResponse.TagDto(tagName)); - } - - dto.setTags(tags); - dtos.add(dto); - } - - return ResponseEntity.ok(dtos); - } - @Transactional public void deleteSharedGame(Long id, UUID uuid) { User user = userRepository.findById(id) @@ -230,14 +195,9 @@ public ResponseEntity> getPublicSharedGames dto.setSharedGameUuid(g.getUuid()); dto.setThumbnailUrl(g.getThumbnailUrl()); dto.setTitle(g.getTitle()); - dto.setSharedAt(g.getSharedAt()); // 집계 - dto.setTotalPlayCount(sharedGameScoreRepository.countBySharedGameId(g.getId())); - dto.setLikeCount(sharedGameFavoriteRepository.countBySharedGameId(g.getId())); - - Long topScore = sharedGameScoreRepository.maxScoreBySharedGameId(g.getId()); - dto.setTopScore(topScore == null ? 0L : topScore); + dto.setPlayCount(sharedGameScoreRepository.countBySharedGameId(g.getId())); // 태그 dto.setTags(gameTagRepository.findTagDtosBySharedGameId(g.getId())); From 7318c879643e714f68d3f4b8413c8282a4d03b9a Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 5 Oct 2025 17:49:23 +0900 Subject: [PATCH 507/527] refactor-shared-game edit --- .../com/scriptopia/demo/controller/SharedGameController.java | 4 ++-- .../demo/dto/sharedgame/PublicSharedGameDetailResponse.java | 2 +- .../java/com/scriptopia/demo/service/SharedGameService.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index dd867480..80207f17 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -48,10 +48,10 @@ public ResponseEntity share(Authentication authentication, @PathVariable("sha @GetMapping public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUUID", required = false) UUID lastUUID, @RequestParam(value = "size", defaultValue = "20") int size, - @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "tags", required = false) List tags, @RequestParam(value = "query", required = false) String query, @RequestParam(value = "sort", defaultValue = "POPULAR") SharedGameSort sort) { - return sharedGameService.getPublicSharedGames(lastUUID, size, tagIds, query, sort); + return sharedGameService.getPublicSharedGames(lastUUID, size, tags, query, sort); } /* diff --git a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java index 32dccbcb..c646a976 100644 --- a/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/sharedgame/PublicSharedGameDetailResponse.java @@ -10,7 +10,7 @@ @Data public class PublicSharedGameDetailResponse { - private UUID sharedGameUuID; + private UUID sharedGameUuid; private String posterUrl; private String title; private String worldView; diff --git a/src/main/java/com/scriptopia/demo/service/SharedGameService.java b/src/main/java/com/scriptopia/demo/service/SharedGameService.java index bc2a71fe..8ac0f3fb 100644 --- a/src/main/java/com/scriptopia/demo/service/SharedGameService.java +++ b/src/main/java/com/scriptopia/demo/service/SharedGameService.java @@ -83,7 +83,7 @@ public ResponseEntity getDetailedSharedGame(Long userId, UUID uuid) { boolean isLiked = (userId != null) && sharedGameFavoriteRepository.existsByUserIdAndSharedGameId(userId, game.getId()); PublicSharedGameDetailResponse dto = new PublicSharedGameDetailResponse(); - dto.setSharedGameUuID(game.getUuid()); + dto.setSharedGameUuid(game.getUuid()); dto.setPosterUrl(game.getThumbnailUrl()); dto.setTitle(game.getTitle()); dto.setWorldView(game.getWorldView()); From 923958a3c329b3cd35df1fc7935a9f78b0a88ee0 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Sun, 5 Oct 2025 23:54:21 +0900 Subject: [PATCH 508/527] refactor-shared-game-edit --- .../com/scriptopia/demo/controller/SharedGameController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 80207f17..03e1afec 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -46,12 +46,12 @@ public ResponseEntity share(Authentication authentication, @PathVariable("sha */ @Operation(summary = "공유 게임 목록 조회") @GetMapping - public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUUID", required = false) UUID lastUUID, + public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUuID", required = false) UUID lastUuID, @RequestParam(value = "size", defaultValue = "20") int size, @RequestParam(value = "tags", required = false) List tags, @RequestParam(value = "query", required = false) String query, @RequestParam(value = "sort", defaultValue = "POPULAR") SharedGameSort sort) { - return sharedGameService.getPublicSharedGames(lastUUID, size, tags, query, sort); + return sharedGameService.getPublicSharedGames(lastUuID, size, tags, query, sort); } /* From 8c70c01bc5078666b328d9858c779afd8ba92d06 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sun, 5 Oct 2025 23:57:53 +0900 Subject: [PATCH 509/527] feat: create CommonResponse and apply --- .../demo/controller/AuthController.java | 33 ++++++++++--------- .../scriptopia/demo/dto/CommonResponse.java | 12 +++++++ 2 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/CommonResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 8aaf83e3..1902fc52 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.dto.CommonResponse; import com.scriptopia.demo.dto.auth.*; import com.scriptopia.demo.service.LocalAccountService; import com.scriptopia.demo.service.RefreshTokenService; @@ -29,7 +30,7 @@ public class AuthController { @Operation(summary = "로그아웃") @PostMapping("/logout") - public ResponseEntity logout( + public ResponseEntity logout( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { @@ -37,7 +38,7 @@ public ResponseEntity logout( refreshTokenService.logout(refreshToken); } response.addHeader(HttpHeaders.SET_COOKIE, localAccountService.removeRefreshCookie().toString()); - return ResponseEntity.ok("로그아웃 되었습니다."); + return ResponseEntity.ok(new CommonResponse("로그아웃 되었습니다.")); } @Operation(summary = "로컬 로그인") @@ -53,64 +54,64 @@ public ResponseEntity login( @Operation(summary = "로컬 계정 회원가입") @PostMapping("/register") - public ResponseEntity register( + public ResponseEntity register( @RequestBody @Valid RegisterRequest request ) { localAccountService.register(request); - return ResponseEntity.ok("회원가입에 성공했습니다."); + return ResponseEntity.ok(new CommonResponse("회원가입에 성공했습니다.")); } @Operation(summary = "이메일 중복 검증") @PostMapping("/email/verify") - public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { localAccountService.verifyEmail(request); - return ResponseEntity.ok("사용 가능한 이메일입니다."); + return ResponseEntity.ok(new CommonResponse("사용 가능한 이메일입니다.")); } @Operation(summary = "이메일 인증 코드 전송") @PostMapping("/email/code/send") - public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { + public ResponseEntity sendCode(@RequestBody @Valid SendCodeRequest request) { localAccountService.sendVerificationCode(request.getEmail()); - return ResponseEntity.ok("인증 코드가 이메일로 발송되었습니다."); + return ResponseEntity.ok(new CommonResponse("인증 코드가 이메일로 발송되었습니다.")); } @Operation(summary = "이메일 인증 코드 확인") @PostMapping("/email/code/verify") - public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { + public ResponseEntity verifyCode(@RequestBody @Valid VerifyCodeRequest request) { localAccountService.verifyCode(request.getEmail(), request.getCode()); - return ResponseEntity.ok("이메일 인증이 완료되었습니다."); + return ResponseEntity.ok(new CommonResponse("이메일 인증이 완료되었습니다.")); } @Operation(summary = "비밀번호 초기화 링크 발송") @PostMapping("/password/reset/send") - public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ + public ResponseEntity sendResetMail(@Valid @RequestBody SendCodeRequest request){ localAccountService.sendResetPasswordMail(request.getEmail()); - return ResponseEntity.ok("비밀번호 초기화 링크를 전송했습니다."); + return ResponseEntity.ok(new CommonResponse("비밀번호 초기화 링크를 전송했습니다.")); } @Operation(summary = "비밀번호 초기화") @PatchMapping("/password/reset") - public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { localAccountService.resetPassword(request.getToken(), request.getNewPassword()); - return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); + return ResponseEntity.ok(new CommonResponse("비밀번호가 성공적으로 변경되었습니다.")); } @Operation(summary = "비밀번호 재설정") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PatchMapping("/password/change") - public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, + public ResponseEntity changePassword(@RequestBody @Valid ChangePasswordRequest request, Authentication authentication) { Long userId = Long.valueOf(authentication.getName()); localAccountService.changePassword(userId,request); - return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); + return ResponseEntity.ok(new CommonResponse("비밀번호가 성공적으로 변경되었습니다.")); } diff --git a/src/main/java/com/scriptopia/demo/dto/CommonResponse.java b/src/main/java/com/scriptopia/demo/dto/CommonResponse.java new file mode 100644 index 00000000..5d10f838 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/CommonResponse.java @@ -0,0 +1,12 @@ +package com.scriptopia.demo.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CommonResponse { + String message; +} From a9114ee4d721d8fcc88d1ecfce11dab7b1fffe82 Mon Sep 17 00:00:00 2001 From: KII1ua Date: Wed, 8 Oct 2025 13:58:57 +0900 Subject: [PATCH 510/527] variable change --- .../com/scriptopia/demo/controller/SharedGameController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index 03e1afec..d589732b 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -46,12 +46,12 @@ public ResponseEntity share(Authentication authentication, @PathVariable("sha */ @Operation(summary = "공유 게임 목록 조회") @GetMapping - public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUuID", required = false) UUID lastUuID, + public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUuId", required = false) UUID lastUuId, @RequestParam(value = "size", defaultValue = "20") int size, @RequestParam(value = "tags", required = false) List tags, @RequestParam(value = "query", required = false) String query, @RequestParam(value = "sort", defaultValue = "POPULAR") SharedGameSort sort) { - return sharedGameService.getPublicSharedGames(lastUuID, size, tags, query, sort); + return sharedGameService.getPublicSharedGames(lastUuId, size, tags, query, sort); } /* From d2a822915d3bf2314768012dd94f770f6d756429 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Thu, 9 Oct 2025 03:39:51 +0900 Subject: [PATCH 511/527] feat: refactor shared-game-controller --- .../com/scriptopia/demo/controller/SharedGameController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java index d589732b..e883d7a1 100644 --- a/src/main/java/com/scriptopia/demo/controller/SharedGameController.java +++ b/src/main/java/com/scriptopia/demo/controller/SharedGameController.java @@ -46,12 +46,12 @@ public ResponseEntity share(Authentication authentication, @PathVariable("sha */ @Operation(summary = "공유 게임 목록 조회") @GetMapping - public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUuId", required = false) UUID lastUuId, + public ResponseEntity> getPublicSharedGames(@RequestParam(value = "lastUuid", required = false) UUID lastUuid, @RequestParam(value = "size", defaultValue = "20") int size, @RequestParam(value = "tags", required = false) List tags, @RequestParam(value = "query", required = false) String query, @RequestParam(value = "sort", defaultValue = "POPULAR") SharedGameSort sort) { - return sharedGameService.getPublicSharedGames(lastUuId, size, tags, query, sort); + return sharedGameService.getPublicSharedGames(lastUuid, size, tags, query, sort); } /* From 0c0431789145c0157a6786288436874c655d561d Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Thu, 9 Oct 2025 12:23:12 +0900 Subject: [PATCH 512/527] feat: solving cors error --- src/main/java/com/scriptopia/demo/config/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index 873e344a..d1326ab5 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -26,6 +26,7 @@ import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import java.util.Arrays; +import java.util.Collections; @Slf4j @Configuration @@ -86,7 +87,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMapping public UrlBasedCorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); - config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); +// config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + config.setAllowedOriginPatterns(Collections.singletonList("*")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(Arrays.asList("*")); // Authorization, Content-Type 등 허용 config.setExposedHeaders(Arrays.asList("Authorization")); // 필요시 노출할 헤더 From c6d7ef691f03b76dd968997a70c8839e6adc388f Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Thu, 9 Oct 2025 23:43:37 +0900 Subject: [PATCH 513/527] refactor: if player come to bigEvent setting title value --- .../java/com/scriptopia/demo/service/GameSessionService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index d7f95746..33b1e8fc 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -527,12 +527,15 @@ public GameSessionMongo gameToChoice(Long userId) { fastApiRequest.setCurrentChoice(null); switch (currentEventStage) { case 2: + gameSessionMongo.getHistoryInfo().setEpilogue1Title("Come Stage"); fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue1Content()); break; case 4: + gameSessionMongo.getHistoryInfo().setEpilogue2Title("Come Stage"); fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue2Content()); break; case 6: + gameSessionMongo.getHistoryInfo().setEpilogue3Title("Come Stage"); fastApiRequest.setCurrentStory(gameSessionMongo.getHistoryInfo().getEpilogue3Content()); break; } From 7591d9bb36c294fc0bbd7c22dd405a9f8575a20a Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 10 Oct 2025 01:14:46 +0900 Subject: [PATCH 514/527] feat: create fastAPI reqeust and response to game/title --- .../demo/dto/gamesession/GameTitleRequest.java | 15 +++++++++++++++ .../demo/dto/gamesession/GameTitleResponse.java | 14 ++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java create mode 100644 src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java new file mode 100644 index 00000000..65c07a32 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java @@ -0,0 +1,15 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GameTitleRequest { + private String content; +} diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java new file mode 100644 index 00000000..d8095594 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java @@ -0,0 +1,14 @@ +package com.scriptopia.demo.dto.gamesession; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GameTitleResponse { + private String title; +} From e12e31725f7d33b79fd421ddd7177e7f39a715e7 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 10 Oct 2025 01:15:10 +0900 Subject: [PATCH 515/527] refactor: add fastAPI endPoint --- .../com/scriptopia/demo/config/fastapi/FastApiEndpoint.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java index 08547f89..8a2c84a3 100644 --- a/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java +++ b/src/main/java/com/scriptopia/demo/config/fastapi/FastApiEndpoint.java @@ -6,7 +6,8 @@ public enum FastApiEndpoint { BATTLE("/games/battle"), ITEM("/games/item"), DONE("/games/done"), - END("/games/end"); + END("/games/end"), + TITLE("/games/title"); private final String path; From 477e705467477985043e7ccac380145cce26a3f8 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 10 Oct 2025 01:17:21 +0900 Subject: [PATCH 516/527] refactor: change dto --- .../com/scriptopia/demo/dto/gamesession/GameTitleRequest.java | 3 ++- .../com/scriptopia/demo/dto/gamesession/GameTitleResponse.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java index 65c07a32..920c935d 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; +import java.util.List; @Data @@ -11,5 +12,5 @@ @AllArgsConstructor @NoArgsConstructor public class GameTitleRequest { - private String content; + private List contents; } diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java index d8095594..88397e47 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleResponse.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; @Data @Builder @AllArgsConstructor @NoArgsConstructor public class GameTitleResponse { - private String title; + private List titles; } From e45f8f49fd4fec0149352bada591b319a88531ef Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 10 Oct 2025 01:30:57 +0900 Subject: [PATCH 517/527] refactor: add fastAPI to getTitles --- .../demo/service/FastApiService.java | 13 +++++++++++ .../demo/service/GameSessionService.java | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/FastApiService.java b/src/main/java/com/scriptopia/demo/service/FastApiService.java index 9f39a106..a88490be 100644 --- a/src/main/java/com/scriptopia/demo/service/FastApiService.java +++ b/src/main/java/com/scriptopia/demo/service/FastApiService.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import java.util.List; + @Service @RequiredArgsConstructor public class FastApiService { @@ -75,4 +77,15 @@ public GameEndResponse end(GameEndRequest request) { .block(); } + // 게임 종료 시 빅 이벤트 타이틀 처리 + public GameTitleResponse title(GameTitleRequest request) { + return fastApiWebClient.post() + .uri(FastApiEndpoint.TITLE.getPath()) + .bodyValue(request) + .retrieve() + .bodyToMono(GameTitleResponse.class) + .block(); + } + + } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 33b1e8fc..b8f142bf 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -1287,6 +1287,29 @@ public ResponseEntity gameToEnd(Long userId) { HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); + GameTitleRequest fastApiRequest = new GameTitleRequest(); + if(historyInfoMongo.getBackgroundStory() != null){ + fastApiRequest.getContents().add(historyInfoMongo.getBackgroundStory()); + } + if(historyInfoMongo.getEpilogue1Title() != null && historyInfoMongo.getEpilogue1Content() != null){ + fastApiRequest.getContents().add(historyInfoMongo.getEpilogue1Content()); + } + if(historyInfoMongo.getEpilogue2Title() != null && historyInfoMongo.getEpilogue2Content() != null){ + fastApiRequest.getContents().add(historyInfoMongo.getEpilogue2Content()); + } + if(historyInfoMongo.getEpilogue3Title() != null && historyInfoMongo.getEpilogue3Content() != null){ + fastApiRequest.getContents().add(historyInfoMongo.getEpilogue3Content()); + } + + GameTitleResponse fastApiResponse = fastApiService.title(fastApiRequest); + + List titles = fastApiResponse.getTitles(); + if (titles != null && !titles.isEmpty()) { + if (titles.size() > 0) historyInfoMongo.setTitle(titles.get(0)); + if (titles.size() > 1) historyInfoMongo.setEpilogue1Title(titles.get(1)); + if (titles.size() > 2) historyInfoMongo.setEpilogue2Title(titles.get(2)); + if (titles.size() > 3) historyInfoMongo.setEpilogue3Title(titles.get(3)); + } HistoryRequest historyRequest = HistoryRequest.builder() .thumbnailUrl(null) // 필요 시 From 025fa8b5904676e21a70ccc6cdfa510accc68351 Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Fri, 10 Oct 2025 02:28:42 +0900 Subject: [PATCH 518/527] complete test --- .../demo/dto/gamesession/GameTitleRequest.java | 4 +++- .../demo/service/GameSessionService.java | 16 +++++++++------- src/main/resources/templates/index.html | 8 ++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java index 920c935d..2bbdbfc0 100644 --- a/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/gamesession/GameTitleRequest.java @@ -4,6 +4,8 @@ import lombok.Builder; import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; + +import java.util.ArrayList; import java.util.List; @@ -12,5 +14,5 @@ @AllArgsConstructor @NoArgsConstructor public class GameTitleRequest { - private List contents; + private List contents = new ArrayList<>(); } diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index b8f142bf..24dddecd 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -27,6 +27,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedList; import java.util.List; @Service @@ -1118,7 +1119,7 @@ private void addStats(PlayerInfoMongo player, ItemDefMongo item) { player.setIntelligence(player.getIntelligence() + safeStat(item.getIntelligence())); player.setLuck(player.getLuck() + safeStat(item.getLuck())); if (item.getCategory() == ItemType.ARMOR){ - player.setLife( item.getBaseStat() ); + player.setHealthPoint( item.getBaseStat() ); } } @@ -1288,6 +1289,7 @@ public ResponseEntity gameToEnd(Long userId) { HistoryInfoMongo historyInfoMongo = gameSessionMongo.getHistoryInfo(); GameTitleRequest fastApiRequest = new GameTitleRequest(); + if(historyInfoMongo.getBackgroundStory() != null){ fastApiRequest.getContents().add(historyInfoMongo.getBackgroundStory()); } @@ -1301,15 +1303,15 @@ public ResponseEntity gameToEnd(Long userId) { fastApiRequest.getContents().add(historyInfoMongo.getEpilogue3Content()); } + + GameTitleResponse fastApiResponse = fastApiService.title(fastApiRequest); List titles = fastApiResponse.getTitles(); - if (titles != null && !titles.isEmpty()) { - if (titles.size() > 0) historyInfoMongo.setTitle(titles.get(0)); - if (titles.size() > 1) historyInfoMongo.setEpilogue1Title(titles.get(1)); - if (titles.size() > 2) historyInfoMongo.setEpilogue2Title(titles.get(2)); - if (titles.size() > 3) historyInfoMongo.setEpilogue3Title(titles.get(3)); - } + if (titles.size() > 0) historyInfoMongo.setTitle(titles.get(0)); + if (titles.size() > 1) historyInfoMongo.setEpilogue1Title(titles.get(1)); + if (titles.size() > 2) historyInfoMongo.setEpilogue2Title(titles.get(2)); + if (titles.size() > 3) historyInfoMongo.setEpilogue3Title(titles.get(3)); HistoryRequest historyRequest = HistoryRequest.builder() .thumbnailUrl(null) // 필요 시 diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index cdf14e0b..de235518 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -350,14 +350,14 @@

소유 아이템

}; // 게임 히스토리 API 호출 - const loadGameHistory = async (gameId) => { + const loadGameHistory = async () => { if (!accessToken) { alert('로그인 필요'); return; } try { - const history = await safeFetch(null, `/api/v1/games/${gameId}/history`, { + const history = await safeFetch(null, `/api/v1/games/sss/history`, { method: 'POST', headers: { 'Authorization': 'Bearer ' + accessToken } }); @@ -619,7 +619,7 @@

소유 아이템

gameOverBtn.className = 'btn'; gameOverBtn.textContent = '게임 패배 - 결과 보기'; gameOverBtn.addEventListener('click', async () => { - const history = await loadGameHistory(gameId); + const history = await loadGameHistory(); showHistoryModal(history); // ✅ 모달로 표시 }); choiceContainer.appendChild(gameOverBtn); @@ -632,7 +632,7 @@

소유 아이템

gameClearBtn.className = 'btn'; gameClearBtn.textContent = '게임 승리 - 결과 보기'; gameClearBtn.addEventListener('click', async () => { - const history = await loadGameHistory(gameId); + const history = await loadGameHistory(); showHistoryModal(history); // ✅ 모달로 표시 }); choiceContainer.appendChild(gameClearBtn); From 4bd6196451ecef60d7a31b58ba7f6adf7150fa71 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 1 Nov 2025 22:34:55 +0900 Subject: [PATCH 519/527] feat: implement getUserStatus API --- .../demo/controller/UserController.java | 15 +++++++++++---- .../demo/dto/users/UserStatusResponse.java | 18 ++++++++++++++++++ .../scriptopia/demo/service/UserService.java | 14 ++++++++++++++ src/main/resources/application.yml | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/scriptopia/demo/dto/users/UserStatusResponse.java diff --git a/src/main/java/com/scriptopia/demo/controller/UserController.java b/src/main/java/com/scriptopia/demo/controller/UserController.java index 471fab1f..7489fc07 100644 --- a/src/main/java/com/scriptopia/demo/controller/UserController.java +++ b/src/main/java/com/scriptopia/demo/controller/UserController.java @@ -1,12 +1,10 @@ package com.scriptopia.demo.controller; +import com.scriptopia.demo.domain.UserStatus; import com.scriptopia.demo.dto.history.HistoryPageResponse; import com.scriptopia.demo.dto.history.HistoryPageResponseDto; import com.scriptopia.demo.dto.items.ItemDTO; -import com.scriptopia.demo.dto.users.PiaItemDTO; -import com.scriptopia.demo.dto.users.UserAssetsResponse; -import com.scriptopia.demo.dto.users.UserImageRequest; -import com.scriptopia.demo.dto.users.UserSettingsDTO; +import com.scriptopia.demo.dto.users.*; import com.scriptopia.demo.service.UserCharacterImgService; import com.scriptopia.demo.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -114,4 +112,13 @@ public ResponseEntity getUserCharacterImgs(Authentication authentication) { return userCharacterImgService.getUserCharacterImg(userId); } + @Operation(summary = "사용자 헤더 정보 조회") + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')") + @GetMapping("/status") + public ResponseEntity getUserStatus(Authentication authentication) { + Long userId = Long.valueOf(authentication.getName()); + + return ResponseEntity.ok(userService.getUserStatus(userId)); + } + } diff --git a/src/main/java/com/scriptopia/demo/dto/users/UserStatusResponse.java b/src/main/java/com/scriptopia/demo/dto/users/UserStatusResponse.java new file mode 100644 index 00000000..0d0a54b5 --- /dev/null +++ b/src/main/java/com/scriptopia/demo/dto/users/UserStatusResponse.java @@ -0,0 +1,18 @@ +package com.scriptopia.demo.dto.users; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserStatusResponse { + private String nickname; + private String profileImage; + private Integer ticket; + +} diff --git a/src/main/java/com/scriptopia/demo/service/UserService.java b/src/main/java/com/scriptopia/demo/service/UserService.java index 2667cfe6..b78532c2 100644 --- a/src/main/java/com/scriptopia/demo/service/UserService.java +++ b/src/main/java/com/scriptopia/demo/service/UserService.java @@ -11,6 +11,7 @@ import com.scriptopia.demo.dto.users.PiaItemDTO; import com.scriptopia.demo.dto.users.UserAssetsResponse; import com.scriptopia.demo.dto.users.UserSettingsDTO; +import com.scriptopia.demo.dto.users.UserStatusResponse; import com.scriptopia.demo.exception.CustomException; import com.scriptopia.demo.exception.ErrorCode; import com.scriptopia.demo.mapper.ItemMapper; @@ -135,4 +136,17 @@ public HistoryPageResponseDto fetchMyHistory(Long userId, UUID lastId, int size) return new HistoryPageResponseDto(result, nextCursor, hasNext); } + + @Transactional + public UserStatusResponse getUserStatus(Long userId){ + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.E_404_USER_NOT_FOUND) + ); + + return UserStatusResponse.builder() + .nickname(user.getNickname()) + .profileImage(user.getProfileImgUrl()) + .ticket(0) + .build(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b9a312ad..ad48b020 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: show_sql: true From 9cd93789dbe3f7b6b923d208824db6ca4a5c40d1 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Sat, 1 Nov 2025 23:09:27 +0900 Subject: [PATCH 520/527] feat: update register API --- .../demo/controller/AuthController.java | 12 +++-- .../demo/dto/auth/RegisterRequest.java | 5 ++ .../demo/service/LocalAccountService.java | 51 +++++++++++-------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/AuthController.java b/src/main/java/com/scriptopia/demo/controller/AuthController.java index 1902fc52..2053d3b9 100644 --- a/src/main/java/com/scriptopia/demo/controller/AuthController.java +++ b/src/main/java/com/scriptopia/demo/controller/AuthController.java @@ -11,6 +11,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; @@ -54,11 +55,14 @@ public ResponseEntity login( @Operation(summary = "로컬 계정 회원가입") @PostMapping("/register") - public ResponseEntity register( - @RequestBody @Valid RegisterRequest request + public ResponseEntity register( + + @RequestBody @Valid RegisterRequest req, + HttpServletRequest request, + HttpServletResponse response ) { - localAccountService.register(request); - return ResponseEntity.ok(new CommonResponse("회원가입에 성공했습니다.")); + + return ResponseEntity.status(HttpStatus.CREATED).body(localAccountService.register(req, request, response)); } @Operation(summary = "이메일 중복 검증") diff --git a/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java b/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java index 5e896916..259ae206 100644 --- a/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java +++ b/src/main/java/com/scriptopia/demo/dto/auth/RegisterRequest.java @@ -1,5 +1,6 @@ package com.scriptopia.demo.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -26,4 +27,8 @@ public class RegisterRequest { @NotBlank(message = "E_400_MISSING_NICKNAME") private String nickname; + + @NotBlank(message = "디바이스 식별값이 필요합니다.") + @Schema(description = "디바이스 아이디", example = "1234") + private String deviceId; } diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index fcf95d97..ce7a9a61 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -28,6 +29,7 @@ import static org.thymeleaf.util.StringUtils.length; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -124,8 +126,8 @@ public void verifyCode(String email, String inputCode) { @Transactional - public void register(RegisterRequest request) { - String email = request.getEmail(); + public LoginResponse register(RegisterRequest registerRequest, HttpServletRequest request, HttpServletResponse response) { + String email = registerRequest.getEmail(); //중복 검증 if (localAccountRepository.existsByEmail(email)){ @@ -140,28 +142,28 @@ public void register(RegisterRequest request) { } // 공백 검증 - if (WS.matcher(request.getPassword()).find()) { + if (WS.matcher(registerRequest.getPassword()).find()) { throw new CustomException(ErrorCode.E_400_PASSWORD_WHITESPACE); } - isAvailable(email, request.getNickname()); + isAvailable(email, registerRequest.getNickname()); //user 객체 생성 User user = new User(); - user.setNickname(request.getNickname()); + user.setNickname(registerRequest.getNickname()); user.setPia(0L); user.setCreatedAt(LocalDateTime.now()); - user.setLastLoginAt(null); + user.setLastLoginAt(LocalDateTime.now()); user.setProfileImgUrl(null); user.setRole(Role.USER); user.setLoginType(LoginType.LOCAL); - userRepository.save(user); + User savedUser = userRepository.save(user); //localAccount 객체 생성 LocalAccount localAccount = new LocalAccount(); localAccount.setUser(user); localAccount.setEmail(email); - localAccount.setPassword(passwordEncoder.encode(request.getPassword())); + localAccount.setPassword(passwordEncoder.encode(registerRequest.getPassword())); localAccount.setUpdatedAt(LocalDateTime.now()); localAccount.setStatus(UserStatus.UNVERIFIED); localAccountRepository.save(localAccount); @@ -177,6 +179,10 @@ public void register(RegisterRequest request) { userSetting.setUpdatedAt(LocalDateTime.now()); userSettingRepository.save(userSetting); + return initLoginResponse(savedUser, registerRequest.getDeviceId(), request, response); + + + } @Transactional @@ -185,27 +191,14 @@ public LoginResponse login(LoginRequest req, HttpServletRequest request, HttpSer LocalAccount localAccount = localAccountRepository.findByEmail(req.getEmail()) .orElseThrow(() -> new CustomException(ErrorCode.E_401_INVALID_CREDENTIALS)); - if (!passwordEncoder.matches(req.getPassword(), localAccount.getPassword())) { throw new CustomException(ErrorCode.E_401_INVALID_CREDENTIALS); } - User user = localAccount.getUser(); user.setLastLoginAt(LocalDateTime.now()); - List roles = List.of(user.getRole().toString()); - String access = jwt.createAccessToken(user.getId(), roles); - String refresh = jwt.createRefreshToken(user.getId(), req.getDeviceId()); - - String ip = request.getRemoteAddr(); - String ua = request.getHeader("User-Agent"); - refreshService.saveLoginRefresh(user.getId(), refresh, req.getDeviceId(), ip, ua); - - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); - - - return new LoginResponse(access, prop.accessExpSeconds(), user.getRole()); + return initLoginResponse(user, req.getDeviceId(), request, response); } @Transactional @@ -284,4 +277,18 @@ public ResponseCookie removeRefreshCookie() { .maxAge(0) .build(); } + + public LoginResponse initLoginResponse(User user, String deviceId, HttpServletRequest request, HttpServletResponse response){ + List roles = List.of(user.getRole().toString()); + String access = jwt.createAccessToken(user.getId(), roles); + String refresh = jwt.createRefreshToken(user.getId(), deviceId); + + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + refreshService.saveLoginRefresh(user.getId(), refresh, deviceId, ip, ua); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(refresh).toString()); + + return new LoginResponse(access, prop.accessExpSeconds(), user.getRole()); + } } From f8a0aea05863001d0d0a04593b7c538d36848e7a Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 3 Nov 2025 19:56:30 +0900 Subject: [PATCH 521/527] fix: solved cors in refresh access token --- .../com/scriptopia/demo/config/SecurityConfig.java | 12 ++++++++++-- src/main/resources/application.yml | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java index d1326ab5..0ce22348 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityConfig.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityConfig.java @@ -87,8 +87,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMapping public UrlBasedCorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); -// config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); - config.setAllowedOriginPatterns(Collections.singletonList("*")); + /* + * 로컬 테스트용 + */ + config.setAllowedOriginPatterns(Arrays.asList( + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.*:*", + "http://10.*:*" + )); +// config.setAllowedOriginPatterns(Collections.singletonList("*")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(Arrays.asList("*")); // Authorization, Content-Type 등 허용 config.setExposedHeaders(Arrays.asList("Authorization")); // 필요시 노출할 헤더 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ad48b020..b6b6d07d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: show_sql: true @@ -60,7 +60,7 @@ oauth: auth: jwt: issuer: scriptopia - access-exp-seconds: 1800 + access-exp-seconds: 18000 # 로컬 테스트용 300분으로 변경 refresh-exp-seconds: 1209600 secret: ${JWT_SECRET} From 50fb5cb3a8de53122879cbe805c692ea9084b2c8 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Mon, 3 Nov 2025 20:14:15 +0900 Subject: [PATCH 522/527] fix: solved cors --- .../com/scriptopia/demo/service/LocalAccountService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index ce7a9a61..ba3c6045 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -45,8 +45,8 @@ public class LocalAccountService { private final MailService mailService; private static final String RT_COOKIE = "RT"; - private static final boolean COOKIE_SECURE = true; - private static final String COOKIE_SAMESITE = "None"; + private static final boolean COOKIE_SECURE = false; + private static final String COOKIE_SAME_SITE = "None"; private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); @@ -262,7 +262,7 @@ public ResponseCookie refreshCookie(String value) { return ResponseCookie.from(RT_COOKIE, value) .httpOnly(true) .secure(COOKIE_SECURE) - .sameSite(COOKIE_SAMESITE) + .sameSite(COOKIE_SAME_SITE) .path("/") .maxAge(Duration.ofDays(14)) .build(); @@ -272,7 +272,7 @@ public ResponseCookie removeRefreshCookie() { return ResponseCookie.from(RT_COOKIE, "") .httpOnly(true) .secure(COOKIE_SECURE) - .sameSite(COOKIE_SAMESITE) + .sameSite(COOKIE_SAME_SITE) .path("/") .maxAge(0) .build(); From 25e93691e2addc13fb7c54abdadb79df90ca3c0b Mon Sep 17 00:00:00 2001 From: Yithian01 Date: Tue, 4 Nov 2025 17:11:13 +0900 Subject: [PATCH 523/527] refactor: add gameProgress end & deleteGameSession method --- .../com/scriptopia/demo/service/GameSessionService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/service/GameSessionService.java b/src/main/java/com/scriptopia/demo/service/GameSessionService.java index 24dddecd..3678ef97 100644 --- a/src/main/java/com/scriptopia/demo/service/GameSessionService.java +++ b/src/main/java/com/scriptopia/demo/service/GameSessionService.java @@ -444,11 +444,17 @@ public GameSessionMongo gameProgress(Long userId) { if( gameSessionMongo.getPlayerInfo().getLife() <= 0 ){ // gameOver 메소드 구현 필요 + + gameToEnd(userId); + deleteGameSession(userId, gameId); return gameToEnd(gameSessionMongo, 0); } if ( gameSessionMongo.getProgress() > gameSessionMongo.getStage().size()){ // gmaeClear 즉 + + gameToEnd(userId); + deleteGameSession(userId, gameId); return gameToEnd(gameSessionMongo, 1); } From 36be1f03160912bfc3971f2891af9b28985fcdd7 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Tue, 18 Nov 2025 19:07:03 +0900 Subject: [PATCH 524/527] fix: update samesite to lax --- .../java/com/scriptopia/demo/service/LocalAccountService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java index ba3c6045..02b11368 100644 --- a/src/main/java/com/scriptopia/demo/service/LocalAccountService.java +++ b/src/main/java/com/scriptopia/demo/service/LocalAccountService.java @@ -46,7 +46,7 @@ public class LocalAccountService { private static final String RT_COOKIE = "RT"; private static final boolean COOKIE_SECURE = false; - private static final String COOKIE_SAME_SITE = "None"; + private static final String COOKIE_SAME_SITE = "Lax"; private static final Pattern WS = Pattern.compile("[\\s\\p{Z}\\u200B\\u200C\\u200D\\uFEFF]"); From aa271fcf6611279c0ed0ef789fbd2cb11a982fb6 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 19 Nov 2025 13:41:26 +0900 Subject: [PATCH 525/527] fix: fix refresh --- .../java/com/scriptopia/demo/controller/refreshController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java index e1904024..342a0748 100644 --- a/src/main/java/com/scriptopia/demo/controller/refreshController.java +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -35,7 +35,6 @@ public class refreshController { private static final String COOKIE_SAMESITE = "None"; @Operation(summary = "리프레시 토큰 재발급") - @PreAuthorize("hasAnyAuthority('USER','ADMIN')") @PostMapping("/refresh") public ResponseEntity refresh( @CookieValue(name = RT_COOKIE, required = false) String refreshToken, From 1ec52e4c1e216894c9f86d0d33d5adb4e27654b9 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 19 Nov 2025 14:53:59 +0900 Subject: [PATCH 526/527] fix: soving refresh error --- .../com/scriptopia/demo/controller/refreshController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/scriptopia/demo/controller/refreshController.java b/src/main/java/com/scriptopia/demo/controller/refreshController.java index e1904024..ea4e7634 100644 --- a/src/main/java/com/scriptopia/demo/controller/refreshController.java +++ b/src/main/java/com/scriptopia/demo/controller/refreshController.java @@ -31,8 +31,8 @@ public class refreshController { private final JwtProperties props; private static final String RT_COOKIE = "RT"; - private static final boolean COOKIE_SECURE = true; - private static final String COOKIE_SAMESITE = "None"; + private static final boolean COOKIE_SECURE = false; + private static final String COOKIE_SAMESITE = "Lax"; @Operation(summary = "리프레시 토큰 재발급") @PreAuthorize("hasAnyAuthority('USER','ADMIN')") From 7984bb5c6457c1756f1e9e307528bf142d957183 Mon Sep 17 00:00:00 2001 From: junseo Lee Date: Wed, 19 Nov 2025 15:08:57 +0900 Subject: [PATCH 527/527] feat: add whitelist endpoint /token/refresh --- src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java index 005ff9e6..4ea93762 100644 --- a/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java +++ b/src/main/java/com/scriptopia/demo/config/SecurityWhitelist.java @@ -19,6 +19,8 @@ public class SecurityWhitelist { "/swagger-ui/**", "/shops/pia/items", + "/token/refresh" + }; public static final String[] PUBLIC_GETS = {