diff --git a/src/main/java/com/example/egobook_be/domain/auth/sevice/AuthService.java b/src/main/java/com/example/egobook_be/domain/auth/sevice/AuthService.java index f2e2bc5..23ea296 100644 --- a/src/main/java/com/example/egobook_be/domain/auth/sevice/AuthService.java +++ b/src/main/java/com/example/egobook_be/domain/auth/sevice/AuthService.java @@ -2,8 +2,13 @@ import com.example.egobook_be.domain.auth.dto.req.*; import com.example.egobook_be.domain.auth.dto.res.JwtTokenResDto; +import com.example.egobook_be.domain.shop.entity.Item; +import com.example.egobook_be.domain.shop.entity.UserItem; +import com.example.egobook_be.domain.shop.enums.ShopErrorCode; +import com.example.egobook_be.domain.shop.repository.ItemRepository; +import com.example.egobook_be.domain.shop.repository.UserItemRepository; import com.example.egobook_be.domain.user.entity.Ability; -import com.example.egobook_be.domain.user.entity.RoleType; +import com.example.egobook_be.domain.user.enums.RoleType; import com.example.egobook_be.domain.user.repository.AbilityRepository; import com.example.egobook_be.global.util.*; import com.example.egobook_be.global.util.module.TokenInfo; @@ -28,6 +33,7 @@ import java.time.Duration; import java.time.LocalDateTime; +import java.util.List; /** * Auth 관련 비즈니스 로직을 수행하는 @@ -40,6 +46,8 @@ public class AuthService { private final AuthAccountRepository authAccountRepository; private final RefreshTokenBackupRepository refreshTokenBackupRepository; private final UserRepository userRepository; + private final ItemRepository itemRepository; + private final UserItemRepository userItemRepository; private final AbilityRepository abilityRepository; private final JwtUtil jwtUtil; private final HashingUtil hashingUtil; @@ -86,38 +94,36 @@ public JwtTokenResDto registerGoogle(GoogleJoinReqDto reqDto){ } /* - * 3. 신규 User Entity 생성 (공통 메서드 활용) + * 3. 신규 User Entity 생성 (공통 메서드 활용) (+ 처음 사용자가 회원가입했을 때 받아야할 것들 할당) * - 닉네임: createUser 내부에서 자동 생성된다. (만약 reqDto의 nickname을 쓰고 싶다면 createUser 수정 필요) * - 이메일: Google Payload에서 추출한 이메일을 저장한다. */ User user = createUser(email); - - // 4. Ability Entity 생성 - createAbility(user); + allocateUser(user); /* - * 5. AuthAccount 엔티티 생성 (Google Provider) + * 4. AuthAccount 엔티티 생성 (Google Provider) * - hashedDeviceUid 자리에 hashedGoogleSub를 저장한다. * - recoverToken은 createAuthAccount 내부에서 초기값(null)으로 설정된다. */ AuthAccount authAccount = createAuthAccount(user, Provider.GOOGLE, hashedGoogleSub); - // 6. 토큰 발급을 위한 UserDetails 생성 + // 5. 토큰 발급을 위한 UserDetails 생성 CustomUserDetails userDetails = buildCustomUserDetails(user, authAccount); /* - * 7. Access, Refresh Token 생성 + * 6. Access, Refresh Token 생성 * - **주의**: Google은 Recover Token을 생성하지 않는다. */ TokenInfo accessTokenInfo = jwtUtil.createAccessToken(userDetails); TokenInfo refreshTokenInfo = jwtUtil.createRefreshToken(userDetails); - // 8. Refresh Token을 Table, Redis에 저장하는 Process 수행 + // 7. Refresh Token을 Table, Redis에 저장하는 Process 수행 processRefreshTokenSaving(user, authAccount, refreshTokenInfo); /* - * 9. 클라이언트에게 토큰 반환 + * 8. 클라이언트에게 토큰 반환 * - Google 로그인이므로 recoverToken은 null을 반환한다. */ return buildJwtTokenResDto(accessTokenInfo.token(), refreshTokenInfo.token(), null); @@ -149,40 +155,38 @@ public JwtTokenResDto registerGuest(GuestJoinReqDto reqDto){ throw new CustomException(AuthErrorCode.ALREADY_REGISTERED_USER); } - // 2. 신규 User Entity 생성 + // 2. 신규 User Entity 생성 (+ 처음 사용자가 회원가입했을 때 받아야할 것들 할당) User user = createUser(null); + allocateUser(user); // 3. AuthAccount 엔티티 생성 (Guest Provider) AuthAccount authAccount = createAuthAccount(user, Provider.GUEST, hashedDeviceUid); - // 4 Ability Entity 생성 - createAbility(user); - /* - * 5. 토큰 발급을 위한 UserDetails 생성 + * 4. 토큰 발급을 위한 UserDetails 생성 * (1) 토큰 발급 시 필요한 사용자 인증 정보를 담은 UserAuthDto를 생성한다. * (2) 생성한 UserAuthDto를 기반으로 CustomUserDetails를 생성한다. */ CustomUserDetails userDetails = buildCustomUserDetails(user, authAccount); /* - * 6. Access, Refresh, Recover Token 생성 + * 5. Access, Refresh, Recover Token 생성 */ TokenInfo accessTokenInfo = jwtUtil.createAccessToken(userDetails); TokenInfo refreshTokenInfo = jwtUtil.createRefreshToken(userDetails); TokenInfo recoverTokenInfo = jwtUtil.createRecoverToken(userDetails); /* - * 7. Recover Token을 AuthAccount에 저장 (영구 보관용) + * 6. Recover Token을 AuthAccount에 저장 (영구 보관용) * **주의**: 이때, recoverToken 값은 HmacSHA256으로 해싱하여 저장한다. (단방향 해싱) */ authAccount.updateHashedRecoverToken(hashingUtil.hashingValue(recoverTokenInfo.token())); - // 8. 모든 토큰들을 발급한 뒤, Refresh Token을 Table, Redis에 저장하는 Process를 수행 + // 7. 모든 토큰들을 발급한 뒤, Refresh Token을 Table, Redis에 저장하는 Process를 수행 processRefreshTokenSaving(user, authAccount, refreshTokenInfo); /* - * 9. 클라이언트에게 토큰을 반환 + * 10. 클라이언트에게 토큰을 반환 * recoverToken은 회원가입, refreshToken 재발급 시에만 발급된다. */ return buildJwtTokenResDto(accessTokenInfo.token(), refreshTokenInfo.token(), recoverTokenInfo.token()); @@ -457,6 +461,7 @@ private User createUser(String email){ .nickname(userNicknameGenerator.generateUniqueNickname()) .lastLoginAt(LocalDateTime.now()) .build(); + if(email != null){ user.updateEmail(email); } return userRepository.save(user); // AuthAccount -> User Entity의 연관관계 설정을 위해, UserRepository로 먼저 save한다. } @@ -476,23 +481,6 @@ private AuthAccount createAuthAccount(User user, Provider provider, String hashe return authAccountRepository.save(authAccount); // AuthAccount -> User Entity의 연관관계 설정을 위해, authRepository로 먼저 save한다. } - /** - * user 생성 시 ability 생성 로직 (능력치) - * @param user 연동할 user - * @return - */ - private Ability createAbility(User user) { - Ability ability = Ability.builder() - .user(user) - .empathy(0) - .diligence(0) - .selfEsteem(0) - .positiveThinking(0) - .emotionRegulation(0) - .build(); - return abilityRepository.save(ability); - } - /** * registerGuest - 6. Refresh Token을 RefreshTokenBackup Table에 추가(Update) * 새로 생성한 RefreshToken을 RefreshTokenBackup 테이블에 업데이트 하는 함수이다. @@ -641,4 +629,58 @@ private void processRefreshTokenSaving(User user, AuthAccount authAccount, Token ); registerToRedis(hashedRefreshToken, redisValue, refreshTokenInfo.expiresAt()); } + + /** + * 사용자가 회원가입을 한 뒤, 기본적으로 사용자에게 할당해줘야할 것들을 할당해주는 함수. + * (1) 기본 UserItem 인스턴스 생성 + * (2) 기본 Ability 인스턴스 생성 + * @param user + */ + private void allocateUser(User user){ + // 1. 사용자 UserItems 생성 + List userItems = createDefaultUserItems(user); + + // 2. 사용자 Ability 생성 + Ability ability = createDefaultAbility(user); + } + + + private List createDefaultUserItems(User user){ + /* + * 1. Item들 중 name이 "Default.png"인 데이터들을 조회한다. + * - 기본 아이템들을 못찾으면 예외처리 + */ + List defaultItems = itemRepository.findAllByName("Default.png"); + if(defaultItems.isEmpty()){throw new CustomException(ShopErrorCode.DEFAULT_ITEMS_NOT_FOUND);} + + defaultItems.forEach(defaultItem -> {log.info("{}", defaultItem.getFullUrl("example"));}); + + + // 2. 찾은 아이템들로 UserItem들을 생성해서 테이블에 저장 + List userItems = defaultItems.stream().map(item -> + UserItem.builder() + .user(user) + .item(item) + .isEquipped(true) + .build() + ).toList(); + return userItemRepository.saveAll(userItems); + } + + /** + * user 생성 시 ability 생성 로직 (능력치) + * @param user 연동할 user + * @return + */ + private Ability createDefaultAbility(User user) { + Ability ability = Ability.builder() + .user(user) + .empathy(0) + .diligence(0) + .selfEsteem(0) + .positiveThinking(0) + .emotionRegulation(0) + .build(); + return abilityRepository.save(ability); + } } diff --git a/src/main/java/com/example/egobook_be/domain/shop/controller/ShopController.java b/src/main/java/com/example/egobook_be/domain/shop/controller/ShopController.java new file mode 100644 index 0000000..6584a46 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/controller/ShopController.java @@ -0,0 +1,91 @@ +package com.example.egobook_be.domain.shop.controller; + +import com.example.egobook_be.domain.shop.dto.EquipItemReqDto; +import com.example.egobook_be.domain.shop.dto.ItemInfoResDto; +import com.example.egobook_be.domain.shop.dto.PurchaseItemReqDto; +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import com.example.egobook_be.domain.shop.sevice.ShopService; +import com.example.egobook_be.global.response.GlobalResponse; +import com.example.egobook_be.global.response.SliceResponse; +import io.swagger.v3.oas.annotations.Parameter; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ShopController implements ShopControllerDocs{ + private final ShopService shopService; + + /** + * [특정 카테고리 아이템 리스트 조회] + * GET /shop/items?category=???&slice=1 + */ + @Override + public ResponseEntity>> getItemSlice( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @Parameter(description = "아이템 카테고리", required = true) + @RequestParam("category") ItemCategory category, + + @Parameter(description = "Slice 번호 (1 ~ N)") + @RequestParam(value = "slice", defaultValue = "1") Integer slice, + + @Parameter(description = "Slice 크기") + @RequestParam(value = "size", defaultValue = "6") Integer size + ){ + SliceResponse sliceResponse = shopService.getItemSlice(userId, category, slice, size); + return ResponseEntity + .status(HttpStatus.OK) + .body(GlobalResponse.success(sliceResponse)); + } + + /** + * [아이템 구매] + * POST /shop/purchase + */ + @Override + public ResponseEntity> purchaseItem( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @RequestBody @Valid PurchaseItemReqDto reqDto + ){ + ItemInfoResDto resDto = shopService.purchaseItem(userId, reqDto); + return ResponseEntity + .status(HttpStatus.OK) + .body(GlobalResponse.success(resDto)); + } + + /** + * [아이템 장착/해제] + * PATCH /shop/equip + */ + @Override + public ResponseEntity> equipItem( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @RequestBody @Valid EquipItemReqDto reqDto + ){ + ItemInfoResDto resDto = shopService.equipItem(userId, reqDto); + if(reqDto.isEquipped() == true){ + return ResponseEntity + .status(HttpStatus.OK) + .body(GlobalResponse.success("아이템 장착 성공", resDto)); + } + return ResponseEntity + .status(HttpStatus.OK) + .body(GlobalResponse.success("아이템 해제 성공", resDto)); + } + + +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/controller/ShopControllerDocs.java b/src/main/java/com/example/egobook_be/domain/shop/controller/ShopControllerDocs.java new file mode 100644 index 0000000..0db09d6 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/controller/ShopControllerDocs.java @@ -0,0 +1,120 @@ +package com.example.egobook_be.domain.shop.controller; + +import com.example.egobook_be.domain.shop.dto.EquipItemReqDto; +import com.example.egobook_be.domain.shop.dto.ItemInfoResDto; +import com.example.egobook_be.domain.shop.dto.PurchaseItemReqDto; +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import com.example.egobook_be.global.response.GlobalResponse; +import com.example.egobook_be.global.response.SliceResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Shop Controller", description = "[상점] 관련 API") +@RequestMapping("/shop") +public interface ShopControllerDocs { + @Operation(summary = "상점 아이템 조회", description = """ + 특정 카테고리의 아이템들을 조회하는 API입니다. + + [**Query Parameter**] + - category: **BACK** | **SKIN** | **DECOR_ONE** | **DECOR_TWO** | **BACKGROUND** + - slice: 1 ~ n (slice값은 1부터 시작합니다.) + + [**기능**:] + - 각 카테고리의 n slice의 데이터를 반환합니다. + + [**주의**] + 1. Slice 값을 0을 넣지 않도록 주의하세요. + 2. category 값은 필수입니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "해당 카테고리의 아이템 목록을 찾았습니다.", + content = @Content(schema = @Schema(implementation = SliceResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 값을 보냈습니다.", + content = @Content), + @ApiResponse(responseCode = "401", description = "로그인이 필요합니다.", + content = @Content), + @ApiResponse(responseCode = "404", description = "해당 카테고리의 아이템들을 찾지 못했습니다.", + content = @Content) + }) + @GetMapping("/items") + ResponseEntity>> getItemSlice( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @Parameter(description = "아이템 카테고리", required = true) + @RequestParam("category") ItemCategory category, + + @Parameter(description = "Slice 번호 (1 ~ N)") + @RequestParam(value = "slice", defaultValue = "1") Integer slice, + + @Parameter(description = "Slice 크기") + @RequestParam(value = "size", defaultValue = "6") Integer size + ); + + + @Operation(summary = "아이템 구매 API", description = """ + 특정 아이템을 구매하는 API입니다. + + [**Request Body**] + - itemId : Item의 PK + + [**기능**] + - 특정 아이템을 구매한 뒤 해당 아이템의 상태 정보만 다시 반환합니다. + + [**주의사항**] + - 아이템 ID를 잘못 입력하면 안됩니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "아이템 구매에 성공했습니다.", + content = @Content(schema = @Schema(implementation = ItemInfoResDto.class))), + @ApiResponse(responseCode = "404", description = "해당 아이템을 찾을 수 없습니다.", content = @Content), + @ApiResponse(responseCode = "409", description = "해당 아이템은 이미 구매되었습니다.", content = @Content), + @ApiResponse(responseCode = "500", description = "아이템 구매 과정에서 알 수 없는 오류가 생겼습니다.", content = @Content), + }) + @PostMapping("/purchase") + ResponseEntity> purchaseItem( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @RequestBody @Valid PurchaseItemReqDto reqDto + ); + + @Operation(summary = "아이템 착용/해제 API", description = """ + 보유 중인 아이템을 착용하는 API입니다. + + [**Request Body**] + - itemId : 착용할 Item의 PK + + [**기능**] + - 해당 아이템을 장착(isEquipped = true)/해제(isEquipped = true) 상태로 변경합니다. + - **동일 카테고리의 기존 장착 아이템은 자동으로 해제됩니다.** + - 변경된 아이템의 정보를 반환합니다. + + [**주의사항**] + - **구매하지 않은 아이템(UserItem 미존재)**은 착용/해제할 수 없습니다. (404 Error) + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "아이템 착용/해제에 성공했습니다.", + content = @Content(schema = @Schema(implementation = ItemInfoResDto.class))), + @ApiResponse(responseCode = "404", description = "보유하지 않은 아이템이거나 존재하지 않는 아이템입니다.", content = @Content), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터입니다.", content = @Content) + }) + @PatchMapping("/equip") // 상태 변경이므로 PATCH 사용 + ResponseEntity> equipItem( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @RequestBody @Valid EquipItemReqDto reqDto + ); + +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/dto/EquipItemReqDto.java b/src/main/java/com/example/egobook_be/domain/shop/dto/EquipItemReqDto.java new file mode 100644 index 0000000..2ea382d --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/dto/EquipItemReqDto.java @@ -0,0 +1,15 @@ +package com.example.egobook_be.domain.shop.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record EquipItemReqDto( + @Schema(description = "대상 아이템의 PK", example = "101") + @NotNull(message = "아이템 ID는 필수입니다.") + Long itemId, + + @Schema(description = "착용 여부 설정 (true: 착용, false: 해제)", example = "true") + @NotNull(message = "착용/해제 여부(isEquipped) 값은 필수입니다.") + Boolean isEquipped +) { +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/dto/ItemInfoResDto.java b/src/main/java/com/example/egobook_be/domain/shop/dto/ItemInfoResDto.java new file mode 100644 index 0000000..a569e81 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/dto/ItemInfoResDto.java @@ -0,0 +1,21 @@ +package com.example.egobook_be.domain.shop.dto; + +import lombok.Builder; + +/** + * 한개의 아이템에 대한 정보를 담은 DTO + * @param itemId Item PK + * @param imageUrl Item의 S3 경로 + * @param price 아이템 가격 + * @param isPurchased 해당 아이템이 구매되었는지 여부 + * @param isEquipped 해당 아이템이 장착되었는지 여부 + */ +@Builder +public record ItemInfoResDto( + Long itemId, + String imageUrl, + Integer price, + Boolean isPurchased, + Boolean isEquipped +) { +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/dto/PurchaseItemReqDto.java b/src/main/java/com/example/egobook_be/domain/shop/dto/PurchaseItemReqDto.java new file mode 100644 index 0000000..40846bb --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/dto/PurchaseItemReqDto.java @@ -0,0 +1,13 @@ +package com.example.egobook_be.domain.shop.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record PurchaseItemReqDto( + @Schema(description = "구매할 아이템의 PK", example = "1") + @NotNull + Long itemId +) { +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/dto/UserItemStatusDto.java b/src/main/java/com/example/egobook_be/domain/shop/dto/UserItemStatusDto.java new file mode 100644 index 0000000..83ca9a4 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/dto/UserItemStatusDto.java @@ -0,0 +1,15 @@ +package com.example.egobook_be.domain.shop.dto; + +import lombok.AllArgsConstructor; + +/** + * 사용자가 구매한 아이템의 정보를 담은 Dto + * @param itemId + * @param isEquipped + */ +public record UserItemStatusDto( + Long itemId, + boolean isEquipped +) { + boolean equalsItemId(Long itemId) {return this.itemId.equals(itemId);} +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/entity/Item.java b/src/main/java/com/example/egobook_be/domain/shop/entity/Item.java new file mode 100644 index 0000000..44aca42 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/entity/Item.java @@ -0,0 +1,81 @@ +package com.example.egobook_be.domain.shop.entity; + +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import com.example.egobook_be.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +// Private로 Access를 막아둠으로써, 외부 코드에서 new User(...)로 생성하는 것을 금지시킨다. +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 필수: 기본 생성자 (보안상 protected 권장) +@Table(name = "item", + // 아이템의 path/name명은 unique하도록 설정 + uniqueConstraints = { + @UniqueConstraint( + name = "uk_file_path_name_provider", + columnNames = {"path","name"} + ) + } +) +public class Item extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 1. 경로: S3 폴더 구조 (예: shop/shell) - files/는 제외하고 저장 + @Column(nullable = false, length = 255) + private String path; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ItemCategory category; + + // 2. 파일명: 실제 파일 이름 (예: turtle-skin.png) + @Column(nullable = false, length = 255) + private String name; + + /* + * 3. 가격: NULL 가능 (NULL이면 비매품 혹은 무료 등 비즈니스 로직에 따라 처리) + * - int(원시타입) 대신 Integer(래퍼클래스)를 써야 DB의 NULL을 담을 수 있습니다. + */ + @Column(nullable = true) + private Integer price; + + /* + * 이 아이템을 보유한 유저 매핑 리스트 (양방향 매핑) + * 아이템 자체가 삭제되면, 해당 아이템의 보유 기록(UserItem)도 삭제됨 + * => 수정: Cascade 설정 삭제. 실수로 item을 지웠을 때, 사용자들의 아이템 구매 기록이 다같이 날아갈 수 있기 때문임. + */ + @OneToMany(mappedBy = "item", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List userItems = new ArrayList<>(); + + // --- 비즈니스 로직 (URL 조합 도우미 메서드) --- + /** + * 도메인을 인자로 받아 전체 CloudFront URL을 완성해주는 메서드 + * @param cloudfrontDomain 예: "https://img.egobook.site" + * @return 예: "https://img.egobook.site/shop/shell/turtle-skin.png" + */ + public String getFullUrl(String cloudfrontDomain) { + return cloudfrontDomain + "/" + this.path + "/" + this.name; + } + + /** + * 새로운 Item 객체의 내용으로 해당 데이터를 전부 업데이트 하는 함수 + * @param item 새로운 Item 객체 + */ + public void updateAll(Item item){ + this.path = item.getPath(); + this.category = item.getCategory(); + this.name = item.getName(); + this.price = item.getPrice(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/egobook_be/domain/shop/entity/UserItem.java b/src/main/java/com/example/egobook_be/domain/shop/entity/UserItem.java new file mode 100644 index 0000000..8d80e26 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/entity/UserItem.java @@ -0,0 +1,65 @@ +package com.example.egobook_be.domain.shop.entity; + +import com.example.egobook_be.domain.user.entity.User; // User Entity import 필요 +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +// Private로 Access를 막아둠으로써, 외부 코드에서 new User(...)로 생성하는 것을 금지시킨다. +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) // purchased_at 자동 생성을 위해 필수 +@Table(name = "user_item", + // (userId, itemId) Unique 제약조건 + uniqueConstraints = { + @UniqueConstraint( + name="uk_user_item_provider", // 제약조건 이름 명시 + columnNames = {"user_id", "item_id"}) // 묶을 컬럼들 이름 설정 + } +) +public class UserItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 1. 유저 (FK): User 테이블과 연결 + // FetchType.LAZY는 필수입니다. (성능 최적화) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 2. 아이템 (FK): Item 테이블과 연결 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id", nullable = false) + private Item item; + + // 3. 장착 여부 (기본값 false) + @Column(name = "is_equipped", nullable = false) + @Builder.Default + private Boolean isEquipped = false; + + // 4. 구매 일시 (데이터 생성 시 자동 기록) + @CreatedDate + @Column(name = "purchased_at", nullable = false, updatable = false) + private LocalDateTime purchasedAt; + + + // --- 비즈니스 로직 (상태 변경 메서드) --- + + // 아이템 장착 + public void equip() { + this.isEquipped = true; + } + + // 아이템 해제 + public void unequip() { + this.isEquipped = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/egobook_be/domain/shop/enums/ItemCategory.java b/src/main/java/com/example/egobook_be/domain/shop/enums/ItemCategory.java new file mode 100644 index 0000000..b1c3928 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/enums/ItemCategory.java @@ -0,0 +1,12 @@ +package com.example.egobook_be.domain.shop.enums; + +/** + * 각 아이템의 + */ +public enum ItemCategory { + BACK, // 등껍질 + SKIN, // 거북이 스킨 + DECOR_ONE, // 데코레이션 1 + DECOR_TWO, // 데코레이션 2 + BACKGROUND // 배경 +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/enums/ShopErrorCode.java b/src/main/java/com/example/egobook_be/domain/shop/enums/ShopErrorCode.java new file mode 100644 index 0000000..30fe358 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/enums/ShopErrorCode.java @@ -0,0 +1,30 @@ +package com.example.egobook_be.domain.shop.enums; + +import com.example.egobook_be.global.exception.model.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ShopErrorCode implements BaseErrorCode { + /* + * 400 + */ + INSUFFICIENT_INK_TO_BUY_ITEM(HttpStatus.BAD_REQUEST, "잉크가 부족하여 해당 아이템을 구매할 수 없습니다."), + ITEM_NOT_PURCHASED(HttpStatus.BAD_REQUEST, "아이템이 구매되지 않았습니다."), + + /* + * 404 NOT FOUND + */ + ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 아이템을 찾을 수 없습니다."), + DEFAULT_ITEMS_NOT_FOUND(HttpStatus.NOT_FOUND, "기본 아이템들을 찾을 수 없습니다."), + + /* + * 409 CONFLICT (충돌) + */ + ALREADY_PURCHASED_ITEM(HttpStatus.CONFLICT, "이미 구매된 아이템입니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/loader/ItemInitializer.java b/src/main/java/com/example/egobook_be/domain/shop/loader/ItemInitializer.java new file mode 100644 index 0000000..770f439 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/loader/ItemInitializer.java @@ -0,0 +1,215 @@ +package com.example.egobook_be.domain.shop.loader; + +import com.example.egobook_be.domain.shop.entity.Item; +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import com.example.egobook_be.domain.shop.repository.ItemRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ItemInitializer implements ApplicationRunner { + private final ItemRepository itemRepository; + @Value("${spring.cloud.aws.cloudfront.domain}") + private String cloudfrontDomain; + + // ================================================== + // 파일 생성을 위한 변수들 선언 + // ================================================== + String PATH_BACK = "shop/back"; + String PATH_SKIN = "shop/skin"; + String PATH_DECOR_ONE = "shop/decor1"; + String PATH_DECOR_TWO = "shop/decor2"; + String PATH_BACKGROUND = "shop/background"; + + + @Override + @Transactional + public void run(ApplicationArguments args){ + log.info("🚀 Item 목록을 DB에 최신화합니다."); + // 1. 등록하고 싶은 아이템 목록을 정의합니다. + List items = getInitItemList(); + int updateCount = 0, insertCount = 0; + + /* + * 2. 이미 DB에 존재하는 아이템들을 Map으로 가져온다. + * - itemRepository로 모든 Item 객체를 가져와서 stream.collect()로 Map으로 변환한다. + * - Key: Item::getFullUrl로 만든 Full Url + * - Value: Item 객체 + */ + Map existItemMap = itemRepository.findAll().stream().collect(Collectors.toMap( + item -> item.getFullUrl(cloudfrontDomain), + item -> item + )); + + // 3. DB에 존재하는 아이템들의 정보를 신규 데이터로 수정한다. + for(Item item : items){ + /* + * 3-1. 해당 아이템이 이미 DB에 존재하는지 확인한다.(existItemMap 활용) + * - 해당 아이템이 이미 DB에 존재하는지 확인하면, 새로운 item의 데이터로 기존 데이터를 덮어씌운다. + */ + String fullUrl = item.getFullUrl(cloudfrontDomain); + if(existItemMap.containsKey(fullUrl)){ + Item existItem = existItemMap.get(fullUrl); + existItem.updateAll(item); + updateCount++; + } + // 3-2. 해당 아이템이 DB에 존재하지 + else { + itemRepository.save(item); + insertCount++; + } + } + log.info("✅ 동기화 완료. [아이템 신규 추가: {}건, 아이템 수정: {}건]", insertCount, updateCount); + } + + /** + * 초기화할 아이템 리스트 정의 함수 + * @return + */ + private List getInitItemList(){ + List items = new ArrayList<>(); + /* + * 1. 등껍질(Back) Items 생성 + * 1-1) Default.png + * 1-2) Yellow.png + * 1-3) White.png + * 1-4) Choco.png + * 1-5) Doughnut.png + * 1-6) Berry.png + * 1-7) Turtle.png + * 1-8) Mint.png + * 1-9) CherryBlossoms.png + * 1-10) SkyCloud.png + */ + items.add(buildBackItem("Default.png", 0)); + items.add(buildBackItem("Yellow.png", 250)); + items.add(buildBackItem("White.png", 500)); + items.add(buildBackItem("Choco.png", 300)); + items.add(buildBackItem("Doughnut.png", 350)); + items.add(buildBackItem("Berry.png", 550)); + items.add(buildBackItem("Turtle.png", 350)); + items.add(buildBackItem("Mint.png", 400)); + items.add(buildBackItem("CherryBlossoms.png", 600)); + items.add(buildBackItem("SkyCloud.png", 450)); + + /* + * 2. 거북이 스킨(Skin) Items 생성 + * 2-1) Default.png + * 2-2) Blue.png + * 2-3) White.png + * 2-4) Brown.png + * 2-5) Black.png + * 2-6) Pink.png + */ + items.add(buildSkinItem("Default.png", 0)); + items.add(buildSkinItem("Blue.png", 200)); + items.add(buildSkinItem("White.png", 500)); + items.add(buildSkinItem("Brown.png", 250)); + items.add(buildSkinItem("Black.png", 300)); + items.add(buildSkinItem("Pink.png", 500)); + + /* + * 3. Decor One Items 생성 + * 3-1) Default.png + * 3-2) Marshmallow.png + * 3-3) Wreath.png + * 3-4) Angel.png + * 3-5) Apple.png + * 3-6) CreamFruits.png + * 3-7) RibbonGreen.png + * 3-8) RibbonPink.png + */ + items.add(buildDecorOneItem("Default.png", 0)); + items.add(buildDecorOneItem("Marshmallow.png", 50)); + items.add(buildDecorOneItem("Wreath.png", 400)); + items.add(buildDecorOneItem("Angel.png", 75)); + items.add(buildDecorOneItem("Apple.png", 150)); + items.add(buildDecorOneItem("CreamFruits.png", 400)); + items.add(buildDecorOneItem("RibbonGreen.png", 200)); + items.add(buildDecorOneItem("RibbonPink.png", 250)); + + /* + * 4. Decor Two Items 생성 + * 4-1) Default.png + * 4-2) CustardCream.png + * 4-3) Berrys.png + * 4-4) CreamChocolate.png + * 4-5) CreamCherry.png + * 4-6) Ribbon.png + * 4-7) Clover.png + */ + items.add(buildDecorTwoItem("Default.png", 0)); + items.add(buildDecorTwoItem("CustardCream.png", 50)); + items.add(buildDecorTwoItem("Berrys.png", 400)); + items.add(buildDecorTwoItem("CreamChocolate.png", 75)); + items.add(buildDecorTwoItem("CreamCherry.png", 150)); + items.add(buildDecorTwoItem("Ribbon.png", 400)); + items.add(buildDecorTwoItem("Clover.png", 200)); + + /* + * 5. Background Items 생성 + * 5-1) Default.png + * 5-2) Blossom.png + * 5-3) Beach.png + */ + items.add(buildBackgroundItem("Default.png", 0)); + items.add(buildBackgroundItem("Blossom.png", 0)); + items.add(buildBackgroundItem("Beach.png", 0)); + + return items; + } + + /** + * 1. Back 아이템 생성 함수 + */ + private Item buildBackItem(String name, Integer price){ + return buildItem(PATH_BACK, ItemCategory.BACK, name, price); + } + /** + * 2. Skin 아이템 생성 함수 + */ + private Item buildSkinItem(String name, Integer price){ + return buildItem(PATH_SKIN, ItemCategory.SKIN, name, price); + } + /** + * 3. Decor1 아이템 생성 함수 + */ + private Item buildDecorOneItem(String name, Integer price){ + return buildItem(PATH_DECOR_ONE, ItemCategory.DECOR_ONE, name, price); + } + /** + * 4. Decor2 아이템 생성 함수 + */ + private Item buildDecorTwoItem(String name, Integer price){ + return buildItem(PATH_DECOR_TWO, ItemCategory.DECOR_TWO, name, price); + } + /** + * 5. Background 아이템 생성 함수 + */ + private Item buildBackgroundItem(String name, Integer price){ + return buildItem(PATH_BACKGROUND, ItemCategory.BACKGROUND, name, price); + } + /** + * Item Entity Build하는 함수 + */ + private Item buildItem(String path, ItemCategory category, String name, Integer price){ + return Item.builder() + .path(path) + .category(category) + .name(name) + .price(price) + .build(); + } +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/mapper/ItemMapper.java b/src/main/java/com/example/egobook_be/domain/shop/mapper/ItemMapper.java new file mode 100644 index 0000000..6e58538 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/mapper/ItemMapper.java @@ -0,0 +1,24 @@ +package com.example.egobook_be.domain.shop.mapper; + +import com.example.egobook_be.domain.shop.dto.ItemInfoResDto; +import com.example.egobook_be.domain.shop.entity.Item; +import org.springframework.stereotype.Component; + +@Component +public class ItemMapper { + + /** + * Item -> ItemInfoResDto로 변환하는 함수 + * @param item + * @return + */ + public ItemInfoResDto toItemInfoResDto(Item item, String cloudfrontDomain, Boolean isPurchased, Boolean isEquipped){ + return ItemInfoResDto.builder() + .itemId(item.getId()) + .imageUrl(item.getFullUrl(cloudfrontDomain)) + .price(item.getPrice()) + .isPurchased(isPurchased) + .isEquipped(isEquipped) + .build(); + } +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/mapper/UserItemMapper.java b/src/main/java/com/example/egobook_be/domain/shop/mapper/UserItemMapper.java new file mode 100644 index 0000000..3ca92fd --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/mapper/UserItemMapper.java @@ -0,0 +1,23 @@ +package com.example.egobook_be.domain.shop.mapper; + +import com.example.egobook_be.domain.shop.dto.ItemInfoResDto; +import com.example.egobook_be.domain.shop.entity.Item; +import com.example.egobook_be.domain.shop.entity.UserItem; +import org.springframework.stereotype.Component; + +@Component +public class UserItemMapper { + + /** + * UserItem Entity -> ItemInfoResDto로 변환하는 함수 + */ + public ItemInfoResDto toItemInfoResDto(UserItem userItem, Item item, String cloudfrontDomain) { + return ItemInfoResDto.builder() + .itemId(item.getId()) + .imageUrl(item.getFullUrl(cloudfrontDomain)) + .price(item.getPrice()) + .isPurchased(true) + .isEquipped(userItem.getIsEquipped()) + .build(); + } +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/repository/ItemRepository.java b/src/main/java/com/example/egobook_be/domain/shop/repository/ItemRepository.java new file mode 100644 index 0000000..f7b71bc --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/repository/ItemRepository.java @@ -0,0 +1,18 @@ +package com.example.egobook_be.domain.shop.repository; + +import com.example.egobook_be.domain.shop.entity.Item; + +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + + +public interface ItemRepository extends JpaRepository { + Slice findByCategory(ItemCategory category, Pageable pageable); + + List findAllByName(String name); + +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/repository/UserItemRepository.java b/src/main/java/com/example/egobook_be/domain/shop/repository/UserItemRepository.java new file mode 100644 index 0000000..6d43e1d --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/repository/UserItemRepository.java @@ -0,0 +1,42 @@ +package com.example.egobook_be.domain.shop.repository; + +import com.example.egobook_be.domain.shop.dto.UserItemStatusDto; +import com.example.egobook_be.domain.shop.entity.UserItem; +import com.example.egobook_be.domain.shop.enums.ItemCategory; +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; +import java.util.Set; + +public interface UserItemRepository extends JpaRepository { + /** + * UserItem Table에서 사용자가 구매한 아이템 목록을 Set으로 가져오는 함수 + * @param userId 사용자 PK + * @param itemIds Item들의 PK가 담긴 List + * @return + */ + @Query("select new com.example.egobook_be.domain.shop.dto.UserItemStatusDto(ui.item.id, ui.isEquipped) " + + "from UserItem ui " + + "where ui.user.id = :userId and ui.item.id in :itemIds") + Set findUserItemStatusSetByItem(@Param("userId") Long userId, @Param("itemIds") List itemIds); + + /** + * 해당 사용자가 이미 해당 id의 아이템을 구매했는지 확인하는 함수 + */ + boolean existsByUserIdAndItemId(Long userId, Long itemId); + + @Query("select ui from UserItem ui join fetch ui.item " + + "where ui.user.id = :userId and ui.item.id = :itemId") + Optional findByUserIdAndItemId(@Param("userId") Long userId, + @Param("itemId") Long itemId); + + @Query("select ui from UserItem ui join ui.item i " + + "where ui.user.id = :userId and i.category = :category and ui.isEquipped = true") + List findEquippedItemsByCategory(@Param("userId") Long userId, + @Param("category") ItemCategory category); + + +} diff --git a/src/main/java/com/example/egobook_be/domain/shop/sevice/ShopService.java b/src/main/java/com/example/egobook_be/domain/shop/sevice/ShopService.java new file mode 100644 index 0000000..2ad0d0b --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/shop/sevice/ShopService.java @@ -0,0 +1,187 @@ +package com.example.egobook_be.domain.shop.sevice; + +import com.example.egobook_be.domain.shop.dto.EquipItemReqDto; +import com.example.egobook_be.domain.shop.dto.ItemInfoResDto; +import com.example.egobook_be.domain.shop.dto.PurchaseItemReqDto; +import com.example.egobook_be.domain.shop.dto.UserItemStatusDto; +import com.example.egobook_be.domain.shop.entity.Item; +import com.example.egobook_be.domain.shop.entity.UserItem; +import com.example.egobook_be.domain.shop.enums.ItemCategory; +import com.example.egobook_be.domain.shop.enums.ShopErrorCode; +import com.example.egobook_be.domain.shop.mapper.ItemMapper; +import com.example.egobook_be.domain.shop.mapper.UserItemMapper; +import com.example.egobook_be.domain.shop.repository.ItemRepository; +import com.example.egobook_be.domain.shop.repository.UserItemRepository; +import com.example.egobook_be.domain.user.entity.User; +import com.example.egobook_be.domain.user.enums.UserErrorCode; +import com.example.egobook_be.domain.user.repository.UserRepository; +import com.example.egobook_be.global.exception.CustomException; +import com.example.egobook_be.global.exception.GlobalErrorCode; +import com.example.egobook_be.global.response.SliceResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ShopService { + private final ItemRepository itemRepository; + private final UserItemRepository userItemRepository; + private final UserRepository userRepository; + private final ItemMapper itemMapper; + private final UserItemMapper userItemMapper; + + // 프론트가 접속할 cloudfront의 도메인 주소 + @Value("${spring.cloud.aws.cloudfront.domain}") + private String cloudfrontDomain; + + /** + * 특정 카테고리의 아이템을 무한 스크롤이 가능하도록 Slice로 가져와서 반환하는 api + * @param userId 요청을 한 user의 PK + * @param category 가져올 아이템의 카테고리 + * @param slice 반환할 Slice 번호 + * @param size 한개의 Slice에 들어있는 요소의 개수 + * @return + */ + @Transactional(readOnly = true) + public SliceResponse getItemSlice(Long userId, ItemCategory category, Integer slice, Integer size){ + /* + * 1. Slicing을 위한 Pageable 객체 생성 (아이템 가격 기준으로 오름차순 정렬) + * - 프론트로부터는 Slice값이 1 ~ N으로 오기 때문에, 해당 값을 -1 + * [ 예외 ] + * (1) 입력된 Slice값이 0보다 작은 경우나 null인 경우 + * (2) Size값이 너무 큰 경우, 최대 크기 100으로 제한 두기 + */ + if(slice == null || slice < 0) throw new CustomException(GlobalErrorCode.INVALID_SLICE_VALUE); + int validSize = (size == null || size < 1) ? 6 : Math.min(size, 100); + int pageIndex = (slice >= 1) ? slice - 1 : 0; + Pageable pageable = PageRequest.of(pageIndex, validSize, Sort.by(Sort.Direction.ASC, "price")); + + // 2. 해당 카테고리에 해당하는 Item들 slice로 조회 + Slice sliceEntity = itemRepository.findByCategory(category, pageable); + + /* + * 3. 조회한 아이템들 중, 해당 User가 구매한 item(UserItem)들의 Set을 생성한다. + * (1) 기존 Slice로 조회한 item 목록에서 item ID만 추출 + * - getContent(): Slice에서 List로 내용물을 꺼내오는 함수 + * (2) 추출한 item Id들만큼 UserItem 테이블에서 데이터들 가져오기 (UserItem PK들만 가져온다) + * (3) 가져온 Set을 Map 형식으로 변환 + */ + List itemIds = sliceEntity.getContent().stream().map(Item::getId).toList(); + Set userItemSet = itemIds.isEmpty() ? Collections.emptySet() : userItemRepository.findUserItemStatusSetByItem(userId, itemIds); + Map userItemMap = userItemSet.stream().collect(Collectors.toMap( + UserItemStatusDto::itemId, UserItemStatusDto::isEquipped, + (oldValue, newValue) -> newValue + )); + + /* + * 4. Slice -> SliceResponse Mapping + * - 위에서 찾은 sliceEntity, userItemIds를 이용해서 Entity -> Dto로 변환 + */ + return SliceResponse.of(sliceEntity, item -> { + Boolean isPurchased = userItemMap.containsKey(item.getId()); + return itemMapper.toItemInfoResDto(item, cloudfrontDomain, isPurchased, userItemMap.getOrDefault(item.getId(), false)); + }); + } + + /** + * 특정 아이템을 구매(UserItem 테이블에 추가)하고, 해당 아이템의 정보를 반환해주는 함수 + * @param userId User PK + * @param reqDto PurchaseItemReqDto + * @return ItemInfoResDto + */ + @Transactional + public ItemInfoResDto purchaseItem(Long userId, PurchaseItemReqDto reqDto){ + /* + * 1. 사용자가 보낸 Item을 검증한다. + * - 1) item이 실제로 존재하는 Item인가? + * - 2) 해당 사용자가 이미 구매한 Item인가? + */ + Long itemId = reqDto.itemId(); + User user = userRepository.findById(userId).orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + Item item = itemRepository.findById(itemId).orElseThrow(() -> new CustomException(ShopErrorCode.ITEM_NOT_FOUND)); + if (userItemRepository.existsByUserIdAndItemId(userId, itemId)){ + throw new CustomException(ShopErrorCode.ALREADY_PURCHASED_ITEM); + } + + // 2. 해당 사용자가 아이템을 살 수 있는지 확인한다. + if(user.getInk() < item.getPrice()) throw new CustomException(ShopErrorCode.INSUFFICIENT_INK_TO_BUY_ITEM); + + // 3. 해당 아이템을 구매한다. (UserItem Table에 새로운 객체를 추가한다.) + UserItem userItem = UserItem.builder() + .user(user) + .item(item) + .isEquipped(false) + .build(); + userItem = userItemRepository.save(userItem); + user.purchaseItem(item.getPrice()); + + // 4. 아이템 구매 후, 해당 아이템에 대한 정보를 반환한다. + return userItemMapper.toItemInfoResDto(userItem, item, cloudfrontDomain); + } + + /** + * 아이템 착용/해제 상태를 변경하는 함수 (멱등성 보장) + * @param userId User PK + * @param reqDto EquipItemReqDto (itemId, isEquipped) + * @return ItemInfoResDto + */ + @Transactional + public ItemInfoResDto equipItem(Long userId, EquipItemReqDto reqDto) { + Long itemId = reqDto.itemId(); + boolean targetStatus = reqDto.isEquipped(); + + /* + * 1. 대상 아이템 조회 (보유 여부 검증) + * - UserItem이 없으면 구매하지 않은 아이템이므로 예외 발생 (404) + * - N+1 방지를 위해 Fetch Join 된 메서드 사용 + */ + UserItem targetUserItem = userItemRepository.findByUserIdAndItemId(userId, itemId) + .orElseThrow(() -> new CustomException(ShopErrorCode.ITEM_NOT_PURCHASED)); + + // 2. 멱등성 체크: 이미 원하는 상태라면 DB 변경 없이 바로 반환 (불필요한 쿼리 방지) + if (targetUserItem.getIsEquipped() == targetStatus) { + return userItemMapper.toItemInfoResDto(targetUserItem, targetUserItem.getItem(), cloudfrontDomain); + } + + /* + * 3. 상태 변경 로직 + * Case A: 착용 요청 (true) -> 같은 카테고리의 기존 아이템 해제 후 착용 + * Case B: 해제 요청 (false) -> 그냥 해제 + */ + if (targetStatus) { + // [Case A] 착용 로직 + ItemCategory category = targetUserItem.getItem().getCategory(); + + // 3-1. 해당 카테고리에 이미 착용 중인 다른 아이템들을 모두 찾아서 해제 + List equippedItems = userItemRepository.findEquippedItemsByCategory(userId, category); + for (UserItem equippedItem : equippedItems) { + // 방어 로직: 혹시라도 자기 자신이 리스트에 포함되어 있다면 skip (이미 위에서 체크했지만 안전하게) + if (!equippedItem.getId().equals(targetUserItem.getId())) { + equippedItem.unequip(); // isEquipped = false + } + } + // 3-2. 대상 아이템 착용 + targetUserItem.equip(); // isEquipped = true + + } else { + // [Case B] 해제 로직 + targetUserItem.unequip(); + } + + // 4. 변경된 정보 반환 + return userItemMapper.toItemInfoResDto(targetUserItem, targetUserItem.getItem(), cloudfrontDomain); + } + +} diff --git a/src/main/java/com/example/egobook_be/domain/user/entity/User.java b/src/main/java/com/example/egobook_be/domain/user/entity/User.java index 4981718..42f3944 100644 --- a/src/main/java/com/example/egobook_be/domain/user/entity/User.java +++ b/src/main/java/com/example/egobook_be/domain/user/entity/User.java @@ -1,11 +1,17 @@ package com.example.egobook_be.domain.user.entity; +import com.example.egobook_be.domain.shop.entity.UserItem; +import com.example.egobook_be.domain.user.enums.RoleType; +import com.example.egobook_be.domain.user.enums.UserStatus; +import com.example.egobook_be.domain.user.enums.WeeklyReportStyle; import com.example.egobook_be.global.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; @Entity @Builder @@ -35,7 +41,7 @@ public class User extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @Builder.Default - private UserStatus status = UserStatus.NEW; + private UserStatus status = UserStatus.ACTIVE; @Column(length = 255) private String email; @@ -80,16 +86,24 @@ public class User extends BaseTimeEntity { private Ability ability; + /* + * 사용자가 보유한 아이템 리스트 (양방향 매핑) + * User가 삭제되면 보유 목록도 같이 삭제되도록 CascadeType.ALL 설정 + */ + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List userItems = new ArrayList<>(); + // ========= Entity 비즈니스 메서드 ========= // /** * 사용자가 login했을 때 User Entity 스스로가 자신의 상태를 최신으로 갱신하는 함수 * - 함수 동작 * 1. 접속 시간 갱신 - * 2. 상태 변경 - 현재 상태가 NEW이거나 DORMANT(휴면)일 경우, 활동 중(ACTIVE) 상태로 변경한다. + * 2. 상태 변경 - 현재 상태가 DORMANT(휴면)일 경우, 활동 중(ACTIVE) 상태로 변경한다. */ public void login() { this.lastLoginAt = LocalDateTime.now(); - if (this.status == UserStatus.NEW || this.status == UserStatus.DORMANT) { + if (this.status == UserStatus.DORMANT) { this.status = UserStatus.ACTIVE; } } @@ -116,4 +130,8 @@ public void deleteUser(Long purgeDurationInMs) { public void addInk(int amount) { this.ink += amount; } + + public void purchaseItem(int price){ + this.ink -= price; + } } diff --git a/src/main/java/com/example/egobook_be/domain/user/entity/RoleType.java b/src/main/java/com/example/egobook_be/domain/user/enums/RoleType.java similarity index 78% rename from src/main/java/com/example/egobook_be/domain/user/entity/RoleType.java rename to src/main/java/com/example/egobook_be/domain/user/enums/RoleType.java index ea792f9..388672b 100644 --- a/src/main/java/com/example/egobook_be/domain/user/entity/RoleType.java +++ b/src/main/java/com/example/egobook_be/domain/user/enums/RoleType.java @@ -1,4 +1,4 @@ -package com.example.egobook_be.domain.user.entity; +package com.example.egobook_be.domain.user.enums; /** * 사용자의 권한을 담고 있는 Enum Class diff --git a/src/main/java/com/example/egobook_be/domain/user/enums/UserErrorCode.java b/src/main/java/com/example/egobook_be/domain/user/enums/UserErrorCode.java new file mode 100644 index 0000000..f77c548 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/user/enums/UserErrorCode.java @@ -0,0 +1,18 @@ +package com.example.egobook_be.domain.user.enums; + +import com.example.egobook_be.global.exception.model.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + /* + * 404 NOT FOUND + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사용자를 찾지 못했습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/example/egobook_be/domain/user/entity/UserStatus.java b/src/main/java/com/example/egobook_be/domain/user/enums/UserStatus.java similarity index 69% rename from src/main/java/com/example/egobook_be/domain/user/entity/UserStatus.java rename to src/main/java/com/example/egobook_be/domain/user/enums/UserStatus.java index e0761d8..2fee9ef 100644 --- a/src/main/java/com/example/egobook_be/domain/user/entity/UserStatus.java +++ b/src/main/java/com/example/egobook_be/domain/user/enums/UserStatus.java @@ -1,7 +1,6 @@ -package com.example.egobook_be.domain.user.entity; +package com.example.egobook_be.domain.user.enums; public enum UserStatus { - NEW, // 신규 가입 ACTIVE, // 활동 중 DORMANT, // 휴면 DELETED_PENDING,// 삭제 대기 (완전 삭제 전 유예 기간) diff --git a/src/main/java/com/example/egobook_be/domain/user/entity/WeeklyReportStyle.java b/src/main/java/com/example/egobook_be/domain/user/enums/WeeklyReportStyle.java similarity index 86% rename from src/main/java/com/example/egobook_be/domain/user/entity/WeeklyReportStyle.java rename to src/main/java/com/example/egobook_be/domain/user/enums/WeeklyReportStyle.java index 1d88ef2..5771155 100644 --- a/src/main/java/com/example/egobook_be/domain/user/entity/WeeklyReportStyle.java +++ b/src/main/java/com/example/egobook_be/domain/user/enums/WeeklyReportStyle.java @@ -1,4 +1,4 @@ -package com.example.egobook_be.domain.user.entity; +package com.example.egobook_be.domain.user.enums; /** * 상담 - 주간 리포트(구독 전용)의 답장 스타일을 선언해둔 Enum Class diff --git a/src/main/java/com/example/egobook_be/global/exception/GlobalErrorCode.java b/src/main/java/com/example/egobook_be/global/exception/GlobalErrorCode.java index 74d9035..9f4c9e6 100644 --- a/src/main/java/com/example/egobook_be/global/exception/GlobalErrorCode.java +++ b/src/main/java/com/example/egobook_be/global/exception/GlobalErrorCode.java @@ -18,6 +18,7 @@ public enum GlobalErrorCode implements BaseErrorCode { INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "입력값의 타입이 유효하지 않습니다."), MISSING_INPUT_VALUE(HttpStatus.BAD_REQUEST,"필수 입력값이 누락되었습니다."), + INVALID_SLICE_VALUE(HttpStatus.BAD_REQUEST, "무한 스크롤을 위한 Slice Page값이 잘못되었습니다. Slice값은 1 ~ N이어야합니다."), /** * 401 UNAUTHORIZED: 인증되지 않음(로그인 실패 등) diff --git a/src/main/java/com/example/egobook_be/global/response/SliceResponse.java b/src/main/java/com/example/egobook_be/global/response/SliceResponse.java index 7b3301d..dd95db3 100644 --- a/src/main/java/com/example/egobook_be/global/response/SliceResponse.java +++ b/src/main/java/com/example/egobook_be/global/response/SliceResponse.java @@ -1,68 +1,68 @@ -package com.example.egobook_be.global.response; + package com.example.egobook_be.global.response; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import org.springframework.data.domain.Slice; + import io.swagger.v3.oas.annotations.media.Schema; + import lombok.Builder; + import org.springframework.data.domain.Slice; -import java.util.List; -import java.util.function.Function; + import java.util.List; + import java.util.function.Function; -@Builder -@Schema(title = "SliceResponse Dto", description = "무한 스크롤 시 Slice된 데이터들 반환 Record") -public record SliceResponse( - List content, // Slicing된 데이터 리스트 - int currentSlice, // 현재 slice 번호 - int size, // 현재 slice 크기(개수) - boolean hasNext // 다음 Slice 존재 여부 -) { + @Builder + @Schema(title = "SliceResponse Dto", description = "무한 스크롤 시 Slice된 데이터들 반환 Record") + public record SliceResponse( + List content, // Slicing된 데이터 리스트 + int currentSlice, // 현재 slice 번호 + int size, // 현재 slice 크기(개수) + boolean hasNext // 다음 Slice 존재 여부 + ) { - /** - * Slice -> SliceResponse로 변환하여 반환하는 함수 - * Slice에 있는 데이터 값을 옮기기만 한다. - * @param slice Slice 형식의 데이터. Slicing 해온 데이터가 담겨있다. - * @return SliceResponse Slicing 정보와 Slicing된 데이터들이 담긴 SliceResponse 객체 - * @param 데이터의 타입 - */ - public static SliceResponse of(Slice slice) { - return SliceResponse.builder() - .content(slice.getContent()) - .currentSlice(slice.getNumber()+1) // 프론트 기준이므로, 실제보다 1 더 많게 반환한다. - .size(slice.getSize()) - .hasNext(slice.hasNext()) - .build(); - } + /** + * Slice -> SliceResponse로 변환하여 반환하는 함수 + * Slice에 있는 데이터 값을 옮기기만 한다. + * @param slice Slice 형식의 데이터. Slicing 해온 데이터가 담겨있다. + * @return SliceResponse Slicing 정보와 Slicing된 데이터들이 담긴 SliceResponse 객체 + * @param 데이터의 타입 + */ + public static SliceResponse of(Slice slice) { + return SliceResponse.builder() + .content(slice.getContent()) + .currentSlice(slice.getNumber()+1) // 프론트 기준이므로, 실제보다 1 더 많게 반환한다. + .size(slice.getSize()) + .hasNext(slice.hasNext()) + .build(); + } - /** - * Slice -> mapper(Entity) -> Dto -> SliceResponse로 변환하는 함수. - * 사용 예시: - * SliceResponse.of(slice, TestMapper::toDto) - * @param slice Slice 객체 - * @param mapper Slice에 들어있는 Entity를 Dto로 변환하는 mapper 함수 - * @return 해당 Slice의 정보들이 담긴 SliceResponse - * @param Entity - * @param Dto - */ - public static SliceResponse of(Slice slice, Function mapper){ /** - * Function mapper: Entity -> mapper -> Dto - * Function은 함수 자체를 인자로 전달받기 위한 타입이다.(자바의 함수형 인터페이스 중 하나임) - * @FunctionalInterface - * public interface Function{ - * R apply(T t); - * } - * => 입력(T)을 넣으면 출력(R)을 반환하는 함수 + * Slice -> mapper(Entity) -> Dto -> SliceResponse로 변환하는 함수. + * 사용 예시: + * SliceResponse.of(slice, TestMapper::toDto) + * @param slice Slice 객체 + * @param mapper Slice에 들어있는 Entity를 Dto로 변환하는 mapper 함수 + * @return 해당 Slice의 정보들이 담긴 SliceResponse + * @param Entity + * @param Dto */ + public static SliceResponse of(Slice slice, Function mapper){ + /** + * Function mapper: Entity -> mapper -> Dto + * Function은 함수 자체를 인자로 전달받기 위한 타입이다.(자바의 함수형 인터페이스 중 하나임) + * @FunctionalInterface + * public interface Function{ + * R apply(T t); + * } + * => 입력(T)을 넣으면 출력(R)을 반환하는 함수 + */ - // 1. 전달 받은 mapper 함수를 이용하여, Slice에 들어있는 Entity들을 Dto로 변환하여 List로 변환한다. - List content = slice.getContent().stream().map(mapper).toList(); + // 1. 전달 받은 mapper 함수를 이용하여, Slice에 들어있는 Entity들을 Dto로 변환하여 List로 변환한다. + List content = slice.getContent().stream().map(mapper).toList(); - // 2. 변환한 content를 SliceResponse에 넣어 설정한 뒤, 다른 세부 설정들도 할당해준다. - return SliceResponse.builder() - .content(content) - .currentSlice(slice.getNumber()+1) - .size(slice.getSize()) - .hasNext(slice.hasNext()) - .build(); - } + // 2. 변환한 content를 SliceResponse에 넣어 설정한 뒤, 다른 세부 설정들도 할당해준다. + return SliceResponse.builder() + .content(content) + .currentSlice(slice.getNumber()+1) + .size(slice.getSize()) + .hasNext(slice.hasNext()) + .build(); + } -} + } diff --git a/src/main/java/com/example/egobook_be/global/security/JwtAuthFilter.java b/src/main/java/com/example/egobook_be/global/security/JwtAuthFilter.java index 7843629..74253c7 100644 --- a/src/main/java/com/example/egobook_be/global/security/JwtAuthFilter.java +++ b/src/main/java/com/example/egobook_be/global/security/JwtAuthFilter.java @@ -2,12 +2,11 @@ import com.example.egobook_be.domain.auth.enums.AuthErrorCode; import com.example.egobook_be.domain.auth.enums.Provider; -import com.example.egobook_be.domain.user.entity.RoleType; +import com.example.egobook_be.domain.user.enums.RoleType; import com.example.egobook_be.global.enums.JwtTokenType; import com.example.egobook_be.global.exception.CustomException; import com.example.egobook_be.global.util.JwtUtil; import com.example.egobook_be.global.util.module.UserAuthDto; -import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -18,7 +17,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; diff --git a/src/main/java/com/example/egobook_be/global/util/JwtUtil.java b/src/main/java/com/example/egobook_be/global/util/JwtUtil.java index 9a2e501..2e9b686 100644 --- a/src/main/java/com/example/egobook_be/global/util/JwtUtil.java +++ b/src/main/java/com/example/egobook_be/global/util/JwtUtil.java @@ -1,6 +1,6 @@ package com.example.egobook_be.global.util; -import com.example.egobook_be.domain.user.entity.RoleType; +import com.example.egobook_be.domain.user.enums.RoleType; import com.example.egobook_be.global.util.module.TokenInfo; import com.example.egobook_be.domain.auth.enums.Provider; import com.example.egobook_be.global.enums.JwtTokenType; diff --git a/src/main/java/com/example/egobook_be/global/util/module/RedisValue.java b/src/main/java/com/example/egobook_be/global/util/module/RedisValue.java index d210459..fc68481 100644 --- a/src/main/java/com/example/egobook_be/global/util/module/RedisValue.java +++ b/src/main/java/com/example/egobook_be/global/util/module/RedisValue.java @@ -1,9 +1,8 @@ package com.example.egobook_be.global.util.module; -import com.example.egobook_be.domain.user.entity.RoleType; +import com.example.egobook_be.domain.user.enums.RoleType; import lombok.Builder; -import java.time.LocalDate; import java.time.LocalDateTime; /** diff --git a/src/main/java/com/example/egobook_be/global/util/module/UserAuthDto.java b/src/main/java/com/example/egobook_be/global/util/module/UserAuthDto.java index 0bd5f1d..7573ee7 100644 --- a/src/main/java/com/example/egobook_be/global/util/module/UserAuthDto.java +++ b/src/main/java/com/example/egobook_be/global/util/module/UserAuthDto.java @@ -1,7 +1,7 @@ package com.example.egobook_be.global.util.module; import com.example.egobook_be.domain.auth.enums.Provider; -import com.example.egobook_be.domain.user.entity.RoleType; +import com.example.egobook_be.domain.user.enums.RoleType; import lombok.Builder; /** diff --git a/src/main/java/com/example/egobook_be/infra/s3/S3ImageService.java b/src/main/java/com/example/egobook_be/infra/s3/S3ImageService.java index c341aae..ce4591c 100644 --- a/src/main/java/com/example/egobook_be/infra/s3/S3ImageService.java +++ b/src/main/java/com/example/egobook_be/infra/s3/S3ImageService.java @@ -39,7 +39,7 @@ public String upload(MultipartFile image) throws IOException{ throw new IllegalArgumentException("이미지 파일이 존재하지 않습니다."); } - /** + /* * 2. 파일명 중복 방지를 위한 유니크한 파일명 생성 * - S3는 같은 이름의 파일이 올라오면 덮어씌워버린다. * - 따라서, UUID(난수, 랜덤 문자열)을 생성해서 파일명 앞에 붙여준다. (ex: a1b2c3d4e5_profile.jpg) @@ -48,7 +48,7 @@ public String upload(MultipartFile image) throws IOException{ String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename; - /** + /* * 3. S3에 업로드 * [ s3Template.upload(bucket, key, stream); ] * - InputStream 사용. 이전 방식은 파일을 서버 디스크에 저장(File)했다가 올리고 지웠는데, diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index afd849b..e429050 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -17,7 +17,11 @@ spring: redis: host: localhost # 같은 EC2 안에 있으므로 localhost port: 6379 - password: ${DEV_REDIS_PW} # PW에 ! 들어갔다가 에러났음 + password: ${DEV_REDIS_PW} # PW에 ! 들어갔다가 에러났음\ + cloud: + aws: + cloudfront: + domain: "https://dev-img.egobook.site" # 프론트가 S3 요소들에 접속하기 위한 도메인 주소 server: url: https://dev.egobook.site diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 05fd732..e661d14 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -19,6 +19,10 @@ spring: host: localhost # 같은 EC2 안에 있으므로 localhost port: 6379 password: ${PROD_REDIS_PW} + cloud: + aws: + cloudfront: + domain: "https://prod-img.egobook.site" # 프론트가 S3 요소들에 접속하기 위한 도메인 주소 server: url: https://www.egobook.site