Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,55 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

// 앱 기동 시 퀘스티 꾸미기 마스터 데이터를 시드한다. 로우별로 존재 여부를 확인해 없는 것만 추가한다.
// 앱 기동 시 퀘스티 꾸미기 마스터 데이터를 시드한다.
// 슬롯 제한: 카테고리별로 없는 것만 추가.
// 아이템: 코드 기준으로 없으면 추가, 있으면 정의가 다를 때 갱신, 목록에서 빠진 활성 아이템은
// 비활성화(#62, RewardMasterDataSeeder.seedBadges와 같은 패턴). image_url은 관리하지 않는다.
@Component
@RequiredArgsConstructor
public class ItemMasterDataSeeder implements CommandLineRunner {

private final EquipSlotLimitRepository equipSlotLimitRepository;
private final ShopItemRepository shopItemRepository;

record ShopItemDefinition(
String code,
String name,
ItemCategory category,
ItemRarity rarity,
int price,
boolean isDefault,
int sortOrder
) {
ShopItem toEntity() {
return ShopItem.seed(code, name, category, rarity, price, isDefault, false, sortOrder);
}
}

// 기본 보유 7종(가격 0, is_default) + 상점 배경 5종(#62). 프론트 화면과 1:1.
// is_featured(카드 별표)는 아직 쓰는 화면이 없어 정의에 두지 않고 시더가 전부 false로 고정한다.
static final List<ShopItemDefinition> SHOP_ITEMS = List.of(
new ShopItemDefinition("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, 1),
new ShopItemDefinition("PURPLE_SUNGLASSES", "보라 선글라스", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, 2),
new ShopItemDefinition("RED_SCARF", "빨간 스카프", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, 3),
new ShopItemDefinition("YELLOW_RAINCOAT", "노란 우비", ItemCategory.OUTFIT, ItemRarity.NORMAL, 0, true, 4),
new ShopItemDefinition("EXPLORER_VEST", "탐험 조끼", ItemCategory.OUTFIT, ItemRarity.NORMAL, 0, true, 5),
new ShopItemDefinition("TRAVEL_BAG", "여행 가방", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, 6),
new ShopItemDefinition("EXPLORER_MAGNIFIER", "탐험 돋보기", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, 7),
new ShopItemDefinition("COZY_CAFE", "포근한 카페", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 320, false, 11),
new ShopItemDefinition("SUNNY_CLASSROOM", "햇살 교실", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 320, false, 12),
new ShopItemDefinition("COZY_ROOM", "포근한 방", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 280, false, 13),
new ShopItemDefinition("BEACH_CAMPING", "바닷가 캠핑", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 360, false, 14),
new ShopItemDefinition("STARLIGHT_OBSERVATORY", "별빛 관측소", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 280, false, 15)
);

@Override
@Transactional
public void run(String... args) {
Expand All @@ -34,7 +72,8 @@ private void seedEquipSlotLimits() {
EquipSlotLimit.seed(ItemCategory.HAT, 1, 1),
EquipSlotLimit.seed(ItemCategory.ACCESSORY, 2, 2),
EquipSlotLimit.seed(ItemCategory.OUTFIT, 1, 3),
EquipSlotLimit.seed(ItemCategory.ITEM, 1, 4)
EquipSlotLimit.seed(ItemCategory.ITEM, 1, 4),
EquipSlotLimit.seed(ItemCategory.BACKGROUND, 1, 5)
)
.filter(limit -> !equipSlotLimitRepository.existsById(limit.getCategory()))
.toList();
Expand All @@ -45,20 +84,34 @@ private void seedEquipSlotLimits() {
}

private void seedShopItems() {
List<ShopItem> missing = Stream.of(
ShopItem.seed("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, false),
ShopItem.seed("SUNGLASSES", "선글라스", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, false),
ShopItem.seed("BLUE_SCARF", "파란 스카프", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, false),
ShopItem.seed("TRAVEL_BAG", "여행 가방", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, false),
ShopItem.seed("GOLDEN_CROWN", "황금 왕관", ItemCategory.HAT, ItemRarity.LEGENDARY, 300, false, false),
ShopItem.seed("TRAVELER_CAP", "여행자 캡", ItemCategory.HAT, ItemRarity.NORMAL, 150, false, false),
ShopItem.seed("GRADUATION_CAP", "학사모", ItemCategory.HAT, ItemRarity.RARE, 200, false, false)
)
.filter(item -> !shopItemRepository.existsByCode(item.getCode()))
.toList();

List<ShopItem> missing = new ArrayList<>();
for (ShopItemDefinition def : SHOP_ITEMS) {
Optional<ShopItem> existing = shopItemRepository.findByCode(def.code());
if (existing.isEmpty()) {
missing.add(def.toEntity());
} else if (needsUpdate(existing.get(), def)) {
existing.get().updateMaster(def.name(), def.category(), def.rarity(), def.price(),
def.isDefault(), false, def.sortOrder());
}
}
if (!missing.isEmpty()) {
shopItemRepository.saveAll(missing);
}

Set<String> knownCodes = SHOP_ITEMS.stream().map(ShopItemDefinition::code).collect(Collectors.toSet());
shopItemRepository.findByIsActiveTrue().stream()
.filter(item -> !knownCodes.contains(item.getCode()))
.forEach(ShopItem::deactivate);
}

private boolean needsUpdate(ShopItem item, ShopItemDefinition def) {
return !def.name().equals(item.getName())
|| def.category() != item.getCategory()
|| def.rarity() != item.getRarity()
|| def.price() != item.getPrice()
|| def.isDefault() != item.getIsDefault()
|| Boolean.TRUE.equals(item.getIsFeatured())
|| def.sortOrder() != item.getSortOrder()
|| !Boolean.TRUE.equals(item.getIsActive());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public class QuestyController {
@Operation(
summary = "내 퀘스티 조회",
description = "홈 화면에서 꾸민 퀘스티를 그리기 위한 장착 아이템(레이어 URL)과 보유 수, 최고 등급을 조회한다. "
+ "서버는 이미지를 합성하지 않는다 — 프론트가 레이어를 겹쳐 그린다."
+ "서버는 이미지를 합성하지 않는다 — 프론트가 레이어를 겹쳐 그린다. "
+ "아직 지급되지 않은 기본 보유 아이템이 있으면 이 시점에 지급된다."
)
public ApiResponse<QuestyResponseDTO> getQuesty(@AuthenticationPrincipal AuthenticatedUser principal) {
return ApiResponse.of(ItemSuccessCode.QUESTY_FOUND, userItemService.getQuesty(principal.userId()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ public ApiResponse<PurchaseItemResponseDTO> purchase(
@GetMapping
@Operation(
summary = "상점 아이템 목록",
description = "판매 중인 상점 아이템 목록을 조회한다. category를 지정하면 해당 카테고리만 반환한다."
description = "판매 중인 상점 아이템 목록을 조회한다. 기본 보유 아이템(isDefault)은 포함하지 않는다. "
+ "category를 지정하면 해당 카테고리만 반환한다."
)
public ApiResponse<List<ShopItemResponseDTO>> getShopItems(
@RequestParam(required = false) ItemCategory category
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public class UserItemController {
@GetMapping
@Operation(
summary = "내 보유 아이템 목록",
description = "로그인한 사용자가 보유한 아이템 목록을 장착 상태와 함께 조회한다."
description = "로그인한 사용자가 보유한 아이템 목록을 장착 상태와 함께 조회한다. "
+ "아직 지급되지 않은 기본 보유 아이템이 있으면 이 시점에 지급된다."
)
public ApiResponse<List<UserItemResponseDTO>> getMyItems(
@AuthenticationPrincipal AuthenticatedUser principal
Expand Down
63 changes: 55 additions & 8 deletions src/main/java/com/quespot/domain/item/entity/ShopItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,10 @@ private ShopItem(
ItemRarity rarity,
Integer price,
Boolean isDefault,
Boolean isFeatured
Boolean isFeatured,
Integer sortOrder
) {
// 음수 가격은 차감 없이 지급되는 경로가 된다(ItemPurchaseService는 price > 0일 때만 차감).
// DB CHECK는 ddl-auto: update가 기존 테이블에 안 붙이므로 Flyway 도입 때 같이 건다.
if (price == null || price < 0) {
throw new IllegalArgumentException("아이템 가격은 0 이상이어야 합니다: " + price);
}
validatePrice(price);
this.code = code;
this.name = name;
this.category = category;
Expand All @@ -86,7 +83,7 @@ private ShopItem(
this.isDefault = isDefault;
this.isFeatured = isFeatured;
this.isActive = true;
this.sortOrder = 0;
this.sortOrder = sortOrder;
}

public static ShopItem seed(
Expand All @@ -98,6 +95,56 @@ public static ShopItem seed(
Boolean isDefault,
Boolean isFeatured
) {
return new ShopItem(code, name, category, rarity, price, isDefault, isFeatured);
return seed(code, name, category, rarity, price, isDefault, isFeatured, 0);
}

public static ShopItem seed(
String code,
String name,
ItemCategory category,
ItemRarity rarity,
Integer price,
Boolean isDefault,
Boolean isFeatured,
Integer sortOrder
) {
return new ShopItem(code, name, category, rarity, price, isDefault, isFeatured, sortOrder);
}

// 마스터 정의가 바뀌었을 때 시더가 호출한다. 비활성이었던 아이템이 정의에 다시 들어오면 활성으로 되돌린다.
// image_url은 시더가 관리하지 않으므로 여기서 건드리지 않는다.
public void updateMaster(
String name,
ItemCategory category,
ItemRarity rarity,
Integer price,
Boolean isDefault,
Boolean isFeatured,
Integer sortOrder
) {
validatePrice(price);
this.name = name;
this.category = category;
this.rarity = rarity;
this.price = price;
this.isDefault = isDefault;
this.isFeatured = isFeatured;
this.sortOrder = sortOrder;
this.isActive = true;
}

// 정의 목록에서 빠진 아이템. 행은 남긴다(보유 이력 보존). 판매·지급·보유 목록·슬롯 계산에서 모두 빠진다.
// user_items.is_equipped는 건드리지 않으므로, 비활성 아이템을 나중에 정의에 되살리면 장착 상태로
// 다시 나타나 슬롯 한도를 넘길 수 있다 — 되살릴 땐 해당 아이템의 장착 행을 먼저 해제할 것.
public void deactivate() {
this.isActive = false;
}

// 음수 가격은 차감 없이 지급되는 경로가 된다(ItemPurchaseService는 price > 0일 때만 차감).
// DB CHECK는 ddl-auto: update가 기존 테이블에 안 붙이므로 Flyway 도입 때 같이 건다.
private static void validatePrice(Integer price) {
if (price == null || price < 0) {
throw new IllegalArgumentException("아이템 가격은 0 이상이어야 합니다: " + price);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum ItemCategory {
HAT,
ACCESSORY,
OUTFIT,
ITEM
ITEM,
BACKGROUND
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@

public interface ShopItemRepository extends JpaRepository<ShopItem, Long> {

List<ShopItem> findByIsActiveTrueOrderBySortOrderAsc();
// 상점 목록. 기본 보유 아이템(is_default)은 지급 대상이지 판매 대상이 아니라 뺀다(#62).
List<ShopItem> findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc();

List<ShopItem> findByCategoryAndIsActiveTrueOrderBySortOrderAsc(ItemCategory category);
List<ShopItem> findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(ItemCategory category);

boolean existsByCode(String code);
Optional<ShopItem> findByCode(String code);

List<ShopItem> findByIsActiveTrue();

Optional<ShopItem> findByIdAndIsActiveTrue(Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand All @@ -13,15 +14,55 @@

public interface UserItemRepository extends JpaRepository<UserItem, Long> {

@Query("select ui from UserItem ui join fetch ui.item where ui.userId = :userId order by ui.purchasedAt desc")
// 비활성 아이템은 보유 목록·퀘스티에서 뺀다(행은 남긴다, #62).
@Query("""
select ui from UserItem ui
join fetch ui.item i
where ui.userId = :userId
and i.isActive = true
order by ui.purchasedAt desc
""")
List<UserItem> findAllByUserIdWithItem(@Param("userId") Long userId);

Optional<UserItem> findByUserIdAndItem_Id(Long userId, Long itemId);

// 장착 대상 조회. 비활성 아이템은 "보유하지 않음"으로 취급한다.
Optional<UserItem> findByUserIdAndItem_IdAndItem_IsActiveTrue(Long userId, Long itemId);

boolean existsByUserIdAndItem_Id(Long userId, Long itemId);

// 같은 유저·카테고리의 UserItem 전체를 잠가 장착 슬롯 한도 위반을 막는다 (동시 장착 요청 대비)
// 같은 유저·카테고리의 UserItem 전체를 잠가 장착 슬롯 한도 위반을 막는다 (동시 장착 요청 대비).
// 비활성 아이템은 슬롯을 차지하지 않는다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select ui from UserItem ui where ui.userId = :userId and ui.item.category = :category")
@Query("""
select ui from UserItem ui
where ui.userId = :userId
and ui.item.category = :category
and ui.item.isActive = true
""")
List<UserItem> lockAllByUserIdAndItem_Category(@Param("userId") Long userId, @Param("category") ItemCategory category);

// 활성 기본 아이템 중 미보유분을 한 번에 지급한다(#62). UNIQUE(user_id, item_id) +
// ON DUPLICATE KEY UPDATE id = id로 멱등 — 동시 호출도 예외 없이 no-op. 같은 트랜잭션에서
// UNIQUE 위반을 catch하면 rollback-only가 되므로(CLAUDE.md) 예외 경로 자체를 없앤다.
// 가격 0이라 포인트 원장·활동 기록은 남기지 않는다.
// NOT EXISTS로 이미 보유한 행은 SELECT 단계에서 걸러낸다 — 없으면 정상 상태(전부 보유)에서도
// 호출마다 ON DUPLICATE KEY가 기존 7행에 X-lock을 잡아 홈 화면 조회가 같은 사용자의 다른 요청과
// 직렬화된다. ON DUPLICATE KEY UPDATE는 동시 첫 요청 둘이 NOT EXISTS를 같이 통과하는 경합의 안전망.
// INSERT ... SELECT에서는 ON DUPLICATE KEY UPDATE의 id가 SELECT 쪽 테이블과 모호해지므로
// 테이블명으로 한정한다(실DB에서 "Column 'id' in field list is ambiguous" 확인).
@Modifying
@Query(value = """
INSERT INTO user_items (user_id, item_id, is_equipped, purchased_at, created_at, updated_at)
SELECT :userId, si.id, 0, NOW(), NOW(), NOW()
FROM shop_items si
WHERE si.is_default = 1
AND si.is_active = 1
AND NOT EXISTS (
SELECT 1 FROM user_items ui
WHERE ui.user_id = :userId AND ui.item_id = si.id
)
ON DUPLICATE KEY UPDATE user_items.id = user_items.id
""", nativeQuery = true)
int grantDefaultItems(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.quespot.domain.item.service;

import com.quespot.domain.item.repository.UserItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

// 기본 보유 아이템(is_default) 지연 지급(#62). 가입 훅은 회원 도메인(허건우)이라 건드리지 않고,
// 보유 목록·퀘스티 조회·장착 진입 시 미보유분을 채운다. 호출부 트랜잭션에 합류한다(REQUIRED) —
// 지급과 이어지는 조회가 어긋날 이유가 없고, 격리가 필요한 독립 항목도 아니다.
@Component
@RequiredArgsConstructor
public class DefaultItemGranter {

private final UserItemRepository userItemRepository;

@Transactional
public void grantMissing(Long userId) {
userItemRepository.grantDefaultItems(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ public class ShopItemService {

private final ShopItemRepository shopItemRepository;

// 상점엔 판매 아이템만(기본 보유 아이템 제외). 현재 마스터 기준으로는 배경 5종이다(#62).
@Transactional(readOnly = true)
public List<ShopItemResponseDTO> getShopItems(ItemCategory category) {
List<ShopItem> shopItems = category == null
? shopItemRepository.findByIsActiveTrueOrderBySortOrderAsc()
: shopItemRepository.findByCategoryAndIsActiveTrueOrderBySortOrderAsc(category);
? shopItemRepository.findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc()
: shopItemRepository.findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(category);

return shopItems.stream()
.map(ItemConverter::toShopItemResponseDTO)
Expand Down
Loading