diff --git a/src/main/java/com/sparta/tdd/domain/coupon/controller/CouponController.java b/src/main/java/com/sparta/tdd/domain/coupon/controller/CouponController.java index 1e46880f..2fe48fd9 100644 --- a/src/main/java/com/sparta/tdd/domain/coupon/controller/CouponController.java +++ b/src/main/java/com/sparta/tdd/domain/coupon/controller/CouponController.java @@ -4,6 +4,7 @@ import com.sparta.tdd.domain.coupon.dto.CouponRequestDto; import com.sparta.tdd.domain.coupon.dto.CouponResponseDto; import com.sparta.tdd.domain.coupon.service.CouponService; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import java.util.List; import java.util.UUID; @@ -28,12 +29,22 @@ public class CouponController { private final CouponService couponService; + @Operation( + summary = "가게 쿠폰 목록 조회", + description = "해당 가게의 모든 쿠폰을 조회합니다." + ) @GetMapping("/list/{storeId}") public ResponseEntity> getStoreCoupons(@PathVariable UUID storeId) { return ResponseEntity.status(HttpStatus.OK) .body(couponService.getStoreCoupons(storeId)); } + @Operation( + summary = "STORE 쿠폰 등록", + description = + "해당 가게에만 적용할 수 있는 STORE 쿠폰을 발급합니다. OWNER, MASTER 권한만 접근 가능하며, 쿠폰 발급 시 최초 쿠폰 SCOPE는 STORE입니다." + + "TYPE 에서 FIXED(고정된 할인값), PERCENT(할인율)을 선택할 수 있으며 사용가능한 최소 금액과 만료기간을 필수로 입력해야 합니다." + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @PostMapping("/{storeId}") public ResponseEntity createStoreCoupon(@PathVariable UUID storeId, @@ -43,6 +54,12 @@ public ResponseEntity createStoreCoupon(@PathVariable UUID st .body(couponService.createStoreCoupon(storeId, dto, userDetails.getUserId())); } + @Operation( + summary = "MASTER 쿠폰 등록", + description = + "가게와 무관하게 적용할 수 있는 MASTER 쿠폰을 발급합니다. MASTER 권한만 접근 가능하며, 쿠폰 발급 시 최초 쿠폰 SCOPE는 MASTER입니다." + + "TYPE 에서 FIXED(고정된 할인값), PERCENT(할인율)을 선택할 수 있으며 사용가능한 최소 금액과 만료기간을 필수로 입력해야 합니다." + ) @PreAuthorize("hasAnyRole('MASTER')") @PostMapping("/master") public ResponseEntity createMasterCoupon( @@ -51,6 +68,10 @@ public ResponseEntity createMasterCoupon( .body(couponService.createMasterCoupon(dto)); } + @Operation( + summary = "쿠폰 수정", + description = "쿠폰을 수정합니다. 해당 가게 점주(OWNER), MASTER 권한만 가능하며 이미 1회라도 사용자가 발급했다면 수정할 수 없습니다." + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @PatchMapping("/{storeId}/{couponId}") public ResponseEntity updateCoupon(@PathVariable UUID storeId, @@ -61,6 +82,12 @@ public ResponseEntity updateCoupon(@PathVariable UUID storeId, return ResponseEntity.noContent().build(); } + @Operation( + summary = "쿠폰 삭제", + description = + "쿠폰을 삭제합니다(sort delete). 해당 가게 점주(OWNER), MASTER 권한만 가능하며 해당 쿠폰이 삭제되어도(추가 발급 불가)" + + " 이미 발급된 유저쿠폰은 적용할 수 있습니다." + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @DeleteMapping("/{storeId}/{couponId}") public ResponseEntity deleteCoupon(@PathVariable UUID storeId, diff --git a/src/main/java/com/sparta/tdd/domain/coupon/controller/UserCouponController.java b/src/main/java/com/sparta/tdd/domain/coupon/controller/UserCouponController.java index 414df85f..a3cdfc9a 100644 --- a/src/main/java/com/sparta/tdd/domain/coupon/controller/UserCouponController.java +++ b/src/main/java/com/sparta/tdd/domain/coupon/controller/UserCouponController.java @@ -3,6 +3,7 @@ import com.sparta.tdd.domain.auth.UserDetailsImpl; import com.sparta.tdd.domain.coupon.dto.UserCouponResponseDto; import com.sparta.tdd.domain.coupon.service.UserCouponService; +import io.swagger.v3.oas.annotations.Operation; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -24,7 +25,11 @@ public class UserCouponController { private final UserCouponService userCouponService; - + @Operation( + summary = "내 쿠폰 목록 조회", + description = "사용자의 쿠폰 목록을 조회합니다. CUSTOMER와 MASTER 권한만 가능하며, ACTIVE 상태 쿠폰부터 최신 발급 순 " + + "> USED/EXPIRED 상태 쿠폰 최신 발급 순으로 조회됩니다. 만료 후 7일이 지난 쿠폰은 조회되지 않습니다." + ) @GetMapping("/my/list") public ResponseEntity> getMyCoupons( @AuthenticationPrincipal UserDetailsImpl userDetails) { @@ -32,6 +37,10 @@ public ResponseEntity> getMyCoupons( .body(userCouponService.getMyCoupons(userDetails.getUserId())); } + @Operation( + summary = "유저쿠폰 발급", + description = "쿠폰을 발급하여 유저쿠폰을 생성합니다. CUSTOMER와 MASTER 권한만 가능하며, 발급된 유저쿠폰의 최초 CouponStatus는 ACTIVE 입니다." + ) @PostMapping("/{couponId}") public ResponseEntity createUserCoupon(@PathVariable UUID couponId, @AuthenticationPrincipal UserDetailsImpl userDetails) { diff --git a/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponRequestDto.java b/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponRequestDto.java index 975146ac..957d4dff 100644 --- a/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponRequestDto.java +++ b/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponRequestDto.java @@ -4,19 +4,28 @@ import com.sparta.tdd.domain.coupon.enums.Scope; import com.sparta.tdd.domain.coupon.enums.Type; import com.sparta.tdd.domain.store.entity.Store; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import lombok.Builder; +@Schema(description = "쿠폰 요청 DTO") @Builder public record CouponRequestDto( + @Schema(description = "쿠폰 이름", example = "2000원 할인 쿠폰") @NotNull @Size(max = 20) String name, + @Schema(description = "할인 종류", example = "FIXED") @NotNull Type type, + @Schema(description = "사용 범위", example = "STORE") @NotNull Scope scope, + @Schema(description = "할인값", example = "2000") @NotNull Integer discountValue, + @Schema(description = "최소 주문 금액", example = "15000") @NotNull Integer minOrderPrice, + @Schema(description = "발행 개수", example = "100") int quantity, + @Schema(description = "만료일자", example = "2025-09-29T12:00:00.000000000") @NotNull LocalDateTime expiredAt ) { diff --git a/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponResponseDto.java b/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponResponseDto.java index b0bdd794..402ab71d 100644 --- a/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponResponseDto.java +++ b/src/main/java/com/sparta/tdd/domain/coupon/dto/CouponResponseDto.java @@ -3,20 +3,31 @@ import com.sparta.tdd.domain.coupon.entity.Coupon; import com.sparta.tdd.domain.coupon.enums.Scope; import com.sparta.tdd.domain.coupon.enums.Type; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import java.util.UUID; import lombok.Builder; +@Schema(description = "쿠폰 응답 DTO") @Builder public record CouponResponseDto( + @Schema(description = "쿠폰 ID", example = "551e8400-e29b-41d4-a716-446655440001") UUID couponId, + @Schema(description = "쿠폰 이름", example = "2000원 할인 쿠폰") String name, + @Schema(description = "할인 종류", example = "FIXED") Type type, + @Schema(description = "사용 범위", example = "STORE") Scope scope, + @Schema(description = "할인값", example = "2000") Integer discountValue, + @Schema(description = "최소 주문 금액", example = "15000") Integer minOrderPrice, + @Schema(description = "발행 개수", example = "100") int quantity, + @Schema(description = "발급된 수량", example = "30") int issuedCount, + @Schema(description = "만료일자", example = "2025-09-29T12:00:00.000000000") LocalDateTime expiredAt ) { diff --git a/src/main/java/com/sparta/tdd/domain/coupon/dto/UserCouponResponseDto.java b/src/main/java/com/sparta/tdd/domain/coupon/dto/UserCouponResponseDto.java index 90ebf09c..aac84834 100644 --- a/src/main/java/com/sparta/tdd/domain/coupon/dto/UserCouponResponseDto.java +++ b/src/main/java/com/sparta/tdd/domain/coupon/dto/UserCouponResponseDto.java @@ -2,14 +2,20 @@ import com.sparta.tdd.domain.coupon.entity.UserCoupon; import com.sparta.tdd.domain.coupon.enums.CouponStatus; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import lombok.Builder; +@Schema(description = "유저쿠폰 응답 DTO") @Builder public record UserCouponResponseDto( + @Schema(description = "유저쿠폰 ID", example = "550e8400-e29b-41d4-a716-446655440000") UUID userCouponId, + @Schema(description = "유저 ID", example = "1") Long userId, + @Schema(description = "쿠폰 ID", example = "551e8400-e29b-41d4-a716-446655440001") UUID couponId, + @Schema(description = "유저 쿠폰 상태", example = "ACTIVE") CouponStatus couponStatus ) { diff --git a/src/main/java/com/sparta/tdd/domain/menu/controller/MenuController.java b/src/main/java/com/sparta/tdd/domain/menu/controller/MenuController.java index dc5bb333..b6d00aa5 100644 --- a/src/main/java/com/sparta/tdd/domain/menu/controller/MenuController.java +++ b/src/main/java/com/sparta/tdd/domain/menu/controller/MenuController.java @@ -5,6 +5,8 @@ import com.sparta.tdd.domain.menu.dto.MenuResponseDto; import com.sparta.tdd.domain.menu.service.MenuService; import com.sparta.tdd.domain.user.enums.UserAuthority; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -29,6 +31,10 @@ public class MenuController { private final MenuService menuService; + @Operation( + summary = "메뉴 목록 조회", + description = "가게의 메뉴 목록을 조회합니다. OWERN나 MASTER가 아니면 숨겨진 메뉴를 볼 수 없습니다." + ) @GetMapping("/{storeId}/menu") public ResponseEntity> getMenus(@PathVariable UUID storeId, @AuthenticationPrincipal UserDetailsImpl userDetails) { @@ -37,6 +43,10 @@ public ResponseEntity> getMenus(@PathVariable UUID storeId .body(menuService.getMenus(storeId, authority)); } + @Operation( + summary = "메뉴 상세 조회", + description = "가게의 특정 메뉴를 조회합니다. OWNER나 MASTER가 아니면 숨겨진 메뉴를 볼 수 없습니다. " + ) @GetMapping("/{storeId}/menu/{menuId}") public ResponseEntity getMenu(@PathVariable UUID storeId, @PathVariable UUID menuId, @@ -46,16 +56,24 @@ public ResponseEntity getMenu(@PathVariable UUID storeId, .body(menuService.getMenu(storeId, menuId, authority)); } + @Operation( + summary = "메뉴 등록", + description = "가게의 메뉴를 등록합니다. OWNER나 MASTER만 가능하며, 해당 가게 점주가 아니라면 예외가 발생합니다. " + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @PostMapping("/{storeId}/menu") public ResponseEntity createMenu(@PathVariable UUID storeId, - @RequestBody MenuRequestDto menuRequestDto, + @Valid @RequestBody MenuRequestDto menuRequestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { Long userId = userDetails.getUserId(); return ResponseEntity.status(HttpStatus.CREATED) .body(menuService.createMenu(storeId, menuRequestDto, userId)); } + @Operation( + summary = "메뉴 수정", + description = "가게의 메뉴를 수정합니다. OWNER나 MASTER만 가능하며, 해당 가게 점주가 아니라면 예외가 발생합니다. " + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @PatchMapping("/{storeId}/menu/{menuId}") public ResponseEntity updateMenu(@PathVariable UUID storeId, @PathVariable UUID menuId, @@ -66,6 +84,10 @@ public ResponseEntity updateMenu(@PathVariable UUID storeId, @PathVariable return ResponseEntity.noContent().build(); } + @Operation( + summary = "메뉴 상태 변경", + description = "가게의 메뉴 상태를 변경합니다. 메뉴를 숨기거나, 공개할 수 있습니다. OWNER나 MASTER만 가능하며, 해당 가게 점주가 아니라면 예외가 발생합니다. " + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @PatchMapping("/{storeId}/menu/{menuId}/status") public ResponseEntity updateMenuStatus(@PathVariable UUID storeId, @@ -77,6 +99,10 @@ public ResponseEntity updateMenuStatus(@PathVariable UUID storeId, return ResponseEntity.noContent().build(); } + @Operation( + summary = "메뉴 삭제", + description = "가게의 메뉴를 삭제합니다. OWNER나 MASTER만 가능하며, 해당 가게 점주가 아니라면 예외가 발생합니다. 메뉴는 soft delete 됩니다. " + ) @PreAuthorize("hasAnyRole('OWNER', 'MASTER')") @DeleteMapping("/{storeId}/menu/{menuId}") public ResponseEntity deleteMenu(@PathVariable UUID storeId, @PathVariable UUID menuId, diff --git a/src/main/java/com/sparta/tdd/domain/menu/dto/MenuRequestDto.java b/src/main/java/com/sparta/tdd/domain/menu/dto/MenuRequestDto.java index 08d98b65..14e0b3d5 100644 --- a/src/main/java/com/sparta/tdd/domain/menu/dto/MenuRequestDto.java +++ b/src/main/java/com/sparta/tdd/domain/menu/dto/MenuRequestDto.java @@ -2,13 +2,19 @@ import com.sparta.tdd.domain.menu.entity.Menu; import com.sparta.tdd.domain.store.entity.Store; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Builder; +@Schema(description = "메뉴 등록 및 수정 요청 DTO") @Builder public record MenuRequestDto( - String name, + @Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거") + @NotNull @Size(max = 20) String name, String description, - Integer price, + @Schema(description = "가격", example = "10000") + @NotNull Integer price, String imageUrl ) { diff --git a/src/main/java/com/sparta/tdd/domain/menu/dto/MenuResponseDto.java b/src/main/java/com/sparta/tdd/domain/menu/dto/MenuResponseDto.java index 114d3206..27c03dde 100644 --- a/src/main/java/com/sparta/tdd/domain/menu/dto/MenuResponseDto.java +++ b/src/main/java/com/sparta/tdd/domain/menu/dto/MenuResponseDto.java @@ -1,16 +1,24 @@ package com.sparta.tdd.domain.menu.dto; import com.sparta.tdd.domain.menu.entity.Menu; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import lombok.Builder; +@Schema(description = "메뉴 응답 DTO") @Builder public record MenuResponseDto( + @Schema(description = "menu 아이디", example = "1") UUID menuId, + @Schema(description = "메뉴명", example = "햄버거") String name, + @Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거") String description, + @Schema(description = "가격", example = "10000") Integer price, + @Schema(description = "메뉴 이미지 URL", example = "https://example.com/images/chicken-burger.jpg") String imageUrl, + @Schema(description = "숨김 여부", example = "Boolean.False") Boolean isHidden ) { diff --git a/src/main/java/com/sparta/tdd/domain/menu/service/MenuService.java b/src/main/java/com/sparta/tdd/domain/menu/service/MenuService.java index ad172293..680744b1 100644 --- a/src/main/java/com/sparta/tdd/domain/menu/service/MenuService.java +++ b/src/main/java/com/sparta/tdd/domain/menu/service/MenuService.java @@ -9,6 +9,8 @@ import com.sparta.tdd.domain.user.entity.User; import com.sparta.tdd.domain.user.enums.UserAuthority; import com.sparta.tdd.domain.user.repository.UserRepository; +import com.sparta.tdd.global.exception.BusinessException; +import com.sparta.tdd.global.exception.ErrorCode; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -40,7 +42,7 @@ public List getMenus(UUID storeId, UserAuthority authority) { public MenuResponseDto getMenu(UUID storeId, UUID menuId, UserAuthority authority) { Menu menu = findMenu(storeId, menuId); if (authority.isCustomerOrManager() && menu.isHidden()) { - throw new IllegalArgumentException("숨겨진 메뉴입니다."); + throw new BusinessException(ErrorCode.MENU_HIDDEN); } return MenuResponseDto.from(menu); } @@ -89,22 +91,22 @@ public void deleteMenu(UUID storeId, UUID menuId, Long userId) { private Menu findMenu(UUID storeId, UUID menuId) { return menuRepository.findByIdAndStoreIdAndIsDeletedFalse(menuId, storeId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); } private Store findStore(UUID storeId) { return storeRepository.findById(storeId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가게입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); } private User findUser(Long userId) { return userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); } private void validateUserOnMenu(User user, Store store) { if (!store.isOwner(user)) { - throw new IllegalArgumentException("권한이 없는 사용자입니다."); + throw new BusinessException(ErrorCode.MENU_PERMISSION_DENIED); } } } diff --git a/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java b/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java index 14af4a79..f00223a7 100644 --- a/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java +++ b/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java @@ -46,6 +46,8 @@ public enum ErrorCode { ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문입니다."), // MENU 도메인 관련 + MENU_HIDDEN(HttpStatus.FORBIDDEN, "숨겨진 메뉴입니다."), + MENU_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "해당 메뉴에 대한 권한이 없습니다."), // REVIEW 도메인 관련 REVIEW_NOT_OWNED(HttpStatus.FORBIDDEN, "본인의 리뷰만 수정/삭제할 수 있습니다."), diff --git a/src/test/java/com/sparta/tdd/domain/menu/MenuIntegrationTest.java b/src/test/java/com/sparta/tdd/domain/menu/MenuIntegrationTest.java index de60d569..ceacc0c0 100644 --- a/src/test/java/com/sparta/tdd/domain/menu/MenuIntegrationTest.java +++ b/src/test/java/com/sparta/tdd/domain/menu/MenuIntegrationTest.java @@ -264,7 +264,7 @@ void ownerDeletesMenu() throws Exception { mockMvc.perform( get("/v1/store/{storeId}/menu/{menuId}", storeId, menuId) .with(csrf())) - .andExpectAll(status().isBadRequest()); + .andExpectAll(status().isNotFound()); } @Test diff --git a/src/test/java/com/sparta/tdd/domain/menu/service/MenuServiceTest.java b/src/test/java/com/sparta/tdd/domain/menu/service/MenuServiceTest.java index 167e0c73..5461f4e1 100644 --- a/src/test/java/com/sparta/tdd/domain/menu/service/MenuServiceTest.java +++ b/src/test/java/com/sparta/tdd/domain/menu/service/MenuServiceTest.java @@ -20,6 +20,7 @@ import com.sparta.tdd.domain.user.entity.User; import com.sparta.tdd.domain.user.enums.UserAuthority; import com.sparta.tdd.domain.user.repository.UserRepository; +import com.sparta.tdd.global.exception.BusinessException; import java.lang.reflect.Field; import java.util.List; import java.util.Optional; @@ -170,7 +171,7 @@ void getMenusCustomerTest() { .thenReturn(Optional.of(menu2)); // when & then - assertThrows(IllegalArgumentException.class, + assertThrows(BusinessException.class, () -> menuService.getMenu(store.getId(), menu2.getId(), customer.getAuthority())); verify(menuRepository, times(1)).findByIdAndStoreIdAndIsDeletedFalse(menu2.getId(), store.getId());