Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c1f230c
[FEAT]: 메뉴 도메인 DTO 추가
twodo0 Jan 29, 2026
a54580d
[FEAT]: 메뉴 API 상태코드 추가
twodo0 Jan 29, 2026
d629370
[FEAT]: 메뉴, 메뉴 이미지 등록, 삭제 로직 추가
twodo0 Jan 29, 2026
1d501f3
[FEAT]: 메뉴 수정 DTO 추가
twodo0 Jan 29, 2026
baf00a3
[FEAT]: 메뉴 수정 성공 응답코드 추가
twodo0 Jan 29, 2026
dc2906a
[FEAT]: 메뉴 수정 로직 추가
twodo0 Jan 29, 2026
228996b
[REFACTOR]: S3 예외처리 세분화
twodo0 Jan 29, 2026
d3a2389
[FEAT]: 메뉴 조회 DTO 및 성공 응답코드 추가
twodo0 Jan 29, 2026
b23b820
[FEAT]: 메뉴 조회 로직 추가
twodo0 Jan 29, 2026
8007772
[FEAT]: 메뉴 조회 시 N+1 방지를 위해 fetch join 쿼리 추가
twodo0 Jan 29, 2026
1f920dd
[FEAT]: 품절여부 변경 DTO 및 성공 응답코드 추가
twodo0 Jan 29, 2026
56c9214
[FEAT]: 메뉴 품절여부 변경 로직 추가
twodo0 Jan 29, 2026
5892557
[FEAT]: 메뉴 삭제 시 soft delete 설정
twodo0 Jan 29, 2026
c15f24d
[FEAT]: @Valid, @RequestParam, @PathVaraible 유효성 검사 실패 시 에러 메시지 보이도록 …
twodo0 Jan 29, 2026
336dd3c
[FEAT]: 이미지 선 업로드에 따른 S3 수명 주기 연동 로직 구현
twodo0 Jan 29, 2026
acccee2
[REFACTOR]: 이미지 Url 리턴하도록 수정
twodo0 Jan 29, 2026
61ec011
[FEAT]: 품절 여부 수정 요청이 기존과 동일하다면 바로 리턴하도록 조건 추가
twodo0 Jan 29, 2026
15e2981
[REFACTOR]: 이미지 삭제 API를 이미 등록된 이미지 삭제하는 것으로 역할 수정
twodo0 Jan 29, 2026
bfc0d40
[REFACTOR]: soft delete된 Menu는 안 가져오도록 쿼리 수정
twodo0 Jan 29, 2026
36a98f4
[REFACTOR]: @Where -> @SQLRestriction 으로 수정
twodo0 Jan 29, 2026
83cfaca
[FIX]: isSuccess(false)로 수정
twodo0 Jan 29, 2026
c20560f
[REFACTOR]: 예약금 정책 변경에 따라 minPrice 필드 삭제 및 로직 삭제
twodo0 Jan 30, 2026
8205906
[REFACTOR]: 남아있던 minPrice 사용하는 로직들 수정
twodo0 Jan 30, 2026
7cb98b0
[FEAT]: 사용하지 않는 removeMenu 메서드 삭제
twodo0 Jan 30, 2026
074c8a4
[REFACTOR]: 메뉴가 없는 가게도 조회될 수 있도록 쿼리 조건 수정
twodo0 Jan 30, 2026
d9cbf9c
[REFACTOR]: 트랜잭션 커밋 이후에 S3에 접근할 수 있도록 로직 수정
twodo0 Jan 30, 2026
df1accc
[FEAT]: moveObject()에서 이동 경로가 동일한 경우 예외처리 추가
twodo0 Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ public enum ImageErrorStatus implements BaseErrorCode {
EMPTY_FILE(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드할 파일이 비어 있습니다."),
INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4002", "지원하지 않는 파일 형식입니다."),
S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE5001", "이미지 업로드에 실패했습니다."),
_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다.")
_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다."),
_INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "IMAGE4003", "유효하지 않은 이미지 키입니다."),
_INVALID_S3_DIRECTORY(HttpStatus.BAD_REQUEST, "IMAGE4004", "유효하지 않은 S3 디렉토리입니다."),
;


Expand All @@ -23,7 +25,7 @@ public enum ImageErrorStatus implements BaseErrorCode {
@Override
public ErrorReasonDto getReason() {
return ErrorReasonDto.builder()
.isSuccess(true)
.isSuccess(false)
.message(message)
.code(code)
.build();
Expand All @@ -33,7 +35,7 @@ public ErrorReasonDto getReason() {
public ErrorReasonDto getReasonHttpStatus() {
return ErrorReasonDto.builder()
.httpStatus(httpStatus)
.isSuccess(true)
.isSuccess(false)
.code(code)
.message(message)
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.eatsfine.eatsfine.domain.menu.controller;

import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto;
import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto;
import com.eatsfine.eatsfine.domain.menu.service.MenuCommandService;
import com.eatsfine.eatsfine.domain.menu.service.MenuQueryService;
import com.eatsfine.eatsfine.domain.menu.status.MenuSuccessStatus;
import com.eatsfine.eatsfine.global.apiPayload.ApiResponse;
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.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "Menu", description = "가게 메뉴 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class MenuController {

private final MenuCommandService menuCommandService;
private final MenuQueryService menuQueryService;

@Operation(summary = "메뉴 이미지 선 업로드 API", description = "메뉴 등록 전에 이미지를 먼저 업로드하고 KEY를 반환합니다.")
@PostMapping(value = "/stores/{storeId}/menus/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MenuResDto.ImageUploadDto> uploadImage(
@PathVariable Long storeId,
@RequestPart("image") MultipartFile file
){
return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_UPLOAD_SUCCESS, menuCommandService.uploadImage(storeId, file));
}

@Operation(summary = "메뉴 등록 API", description = "가게의 메뉴들을 등록합니다.")
@PostMapping("/stores/{storeId}/menus")
public ApiResponse<MenuResDto.MenuCreateDto> createMenus(
@PathVariable Long storeId,
@RequestBody @Valid MenuReqDto.MenuCreateDto dto
) {
return ApiResponse.of(MenuSuccessStatus._MENU_CREATE_SUCCESS, menuCommandService.createMenus(storeId, dto));
}

@Operation(summary = "메뉴 삭제 API", description = "가게의 메뉴들을 삭제합니다.")
@DeleteMapping("/stores/{storeId}/menus")
public ApiResponse<MenuResDto.MenuDeleteDto> deleteMenus(
@PathVariable Long storeId,
@RequestBody @Valid MenuReqDto.MenuDeleteDto dto
) {
return ApiResponse.of(MenuSuccessStatus._MENU_DELETE_SUCCESS, menuCommandService.deleteMenus(storeId, dto));
}

@Operation(summary = "메뉴 수정 API", description = "가게의 메뉴를 수정합니다.")
@PatchMapping("/stores/{storeId}/menus/{menuId}")
public ApiResponse<MenuResDto.MenuUpdateDto> updateMenu(
@PathVariable Long storeId,
@PathVariable Long menuId,
@RequestBody @Valid MenuReqDto.MenuUpdateDto dto
) {
return ApiResponse.of(MenuSuccessStatus._MENU_UPDATE_SUCCESS, menuCommandService.updateMenu(storeId, menuId, dto));
}

@Operation(summary = "품절 여부 변경 API", description = "메뉴의 품절 여부를 변경합니다.")
@PatchMapping("/stores/{storeId}/menus/{menuId}/sold-out")
public ApiResponse<MenuResDto.SoldOutUpdateDto> updateSoldOutStatus(
@PathVariable Long storeId,
@PathVariable Long menuId,
@RequestBody @Valid MenuReqDto.SoldOutUpdateDto dto
){
return ApiResponse.of(MenuSuccessStatus._SOLD_OUT_UPDATE_SUCCESS, menuCommandService.updateSoldOutStatus(storeId, menuId, dto.isSoldOut()));
}

@Operation(summary = "등록된 메뉴 이미지 삭제 API", description = "이미 등록된 메뉴의 이미지를 삭제합니다.")
@DeleteMapping("/stores/{storeId}/menus/{menuId}/image")
public ApiResponse<MenuResDto.ImageDeleteDto> deleteMenuImage(
@PathVariable Long storeId,
@PathVariable Long menuId
) {
return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_DELETE_SUCCESS, menuCommandService.deleteMenuImage(storeId, menuId));
Comment on lines +73 to +79
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

API path mismatch for menu image deletion.
PR objectives and summary list DELETE /stores/{storeId}/menus/images, but the controller exposes /stores/{storeId}/menus/{menuId}/image. Please align the route (or update the API contract/docs) to avoid client breakage.

🤖 Prompt for AI Agents
In
`@src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java`
around lines 73 - 79, The controller route for menu image deletion in
MenuController currently uses
`@DeleteMapping`("/stores/{storeId}/menus/{menuId}/image") in the deleteMenuImage
method, but the PR and API spec expect DELETE /stores/{storeId}/menus/images;
update the controller route to match the agreed contract (or update the API
spec) — either change the mapping string on deleteMenuImage to
"/stores/{storeId}/menus/images" and adjust method parameters (remove or retain
`@PathVariable` Long menuId as per the contract) and any service method signature
calls (menuCommandService.deleteMenuImage) accordingly, and then update related
API docs/tests to reflect the chosen path.

}

@Operation(summary = "메뉴 조회 API", description = "가게의 메뉴들을 조회합니다.")
@GetMapping("/stores/{storeId}/menus")
public ApiResponse<MenuResDto.MenuListDto> getMenus(
@PathVariable Long storeId
) {
return ApiResponse.of(MenuSuccessStatus._MENU_LIST_SUCCESS, menuQueryService.getMenus(storeId));

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.eatsfine.eatsfine.domain.menu.converter;

import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto;
import com.eatsfine.eatsfine.domain.menu.entity.Menu;

import java.util.List;

public class MenuConverter {


public static MenuResDto.ImageUploadDto toImageUploadDto(String imageKey, String imageUrl){
return MenuResDto.ImageUploadDto.builder()
.imageKey(imageKey)
.imageUrl(imageUrl)
.build();
}

public static MenuResDto.ImageDeleteDto toImageDeleteDto(String imageKey) {
return MenuResDto.ImageDeleteDto.builder()
.deletedImageKey(imageKey)
.build();
}


public static MenuResDto.MenuCreateDto toCreateDto(List<MenuResDto.MenuDto> menuDtos) {
return MenuResDto.MenuCreateDto.builder()
.menus(menuDtos)
.build();
}

public static MenuResDto.MenuDeleteDto toDeleteDto(List<Long> menuIds){
return MenuResDto.MenuDeleteDto.builder()
.deletedMenuIds(menuIds)
.build();
}

public static MenuResDto.MenuUpdateDto toUpdateDto(Menu menu, String updatedImageUrl){
return MenuResDto.MenuUpdateDto.builder()
.menuId(menu.getId())
.name(menu.getName())
.description(menu.getDescription())
.price(menu.getPrice())
.category(menu.getMenuCategory())
.imageUrl(updatedImageUrl)
.build();

}

public static MenuResDto.SoldOutUpdateDto toSoldOutUpdateDto(Menu menu){
return MenuResDto.SoldOutUpdateDto.builder()
.menuId(menu.getId())
.isSoldOut(menu.isSoldOut())
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.eatsfine.eatsfine.domain.menu.dto;

import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

import java.math.BigDecimal;
import java.util.List;

public class MenuReqDto {

public record MenuCreateDto(
@Valid
@NotNull
@Size(min = 1, message = "최소 1개 이상의 메뉴를 등록해야 합니다.")
List<MenuDto> menus
){}


public record MenuDto(
@NotBlank(message = "메뉴 이름은 필수입니다.")
String name,

@Size(max = 500, message = "설명은 500자 이내여야 합니다.")
String description,

@NotNull(message = "가격은 필수입니다.")
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
BigDecimal price,

@NotNull(message = "카테고리는 필수입니다.")
MenuCategory category,

String imageKey // 이미지는 선택 사항이므로 검증 없음 (nullable)
){}


public record MenuDeleteDto(
@NotNull
@Size(min = 1, message = "삭제할 메뉴를 최소 1개 이상 선택해주세요.")
List<Long> menuIds
){}

public record MenuUpdateDto(
@Size(min = 1, message = "메뉴 이름은 1글자 이상이어야 합니다.")
String name,
@Size(max = 500, message = "설명은 500자 이내여야 합니다.")
String description,
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
BigDecimal price,
MenuCategory category,
String imageKey
){}

public record SoldOutUpdateDto(
@NotNull
Boolean isSoldOut
){}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@

package com.eatsfine.eatsfine.domain.menu.dto;

import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory;
import lombok.Builder;

import java.math.BigDecimal;
import java.util.List;

public class MenuResDto {

@Builder
public record ImageUploadDto(
String imageKey, // 메뉴 등록/수정 시 서버에 다시 보낼 키
String imageUrl // 프론트엔드에서 즉시 미리보기를 위한 전체 URL
){}


@Builder
public record ImageDeleteDto(
String deletedImageKey
){}

@Builder
public record MenuCreateDto(
List<MenuDto> menus
){}

@Builder
public record MenuDto(
Long menuId,
String name,
String description,
BigDecimal price,
MenuCategory category,
String imageUrl
){}

@Builder
public record MenuDeleteDto(
List<Long> deletedMenuIds
){}

@Builder
public record MenuUpdateDto(
Long menuId,
String name,
String description,
BigDecimal price,
MenuCategory category,
String imageUrl
){}

@Builder
public record SoldOutUpdateDto(
Long menuId,
boolean isSoldOut
){}

@Builder
public record MenuListDto(
List<MenuDetailDto> menus
){}

@Builder
public record MenuDetailDto(
Long menuId,
String name,
String description,
BigDecimal price,
MenuCategory category,
String imageUrl,
boolean isSoldOut
){}
}
25 changes: 25 additions & 0 deletions src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@
import com.eatsfine.eatsfine.global.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.hibernate.annotations.Where;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "menu")
@SQLDelete(sql = "UPDATE menu SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Menu extends BaseEntity {

@Id
Expand Down Expand Up @@ -44,6 +50,8 @@ public class Menu extends BaseEntity {
@Column(name = "is_sold_out", nullable = false)
private boolean isSoldOut = false;

private LocalDateTime deletedAt;

public void assignStore(Store store) {
this.store = store;
}
Expand All @@ -58,4 +66,21 @@ public void updateImageKey(String imageKey) {
this.imageKey = imageKey;
}

// --- 메뉴 정보 수정 메서드 ---

public void updateName(String name) {
this.name = name;
}

public void updateDescription(String description) {
this.description = description;
}

public void updatePrice(BigDecimal price) {
this.price = price;
}

public void updateCategory(MenuCategory menuCategory) {
this.menuCategory = menuCategory;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.eatsfine.eatsfine.domain.menu.exception;

import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode;
import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException;

public class MenuException extends GeneralException {
public MenuException(BaseErrorCode code) {
super(code);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
import com.eatsfine.eatsfine.domain.menu.entity.Menu;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MenuRepository extends JpaRepository<Menu, Long> {
Optional<Menu> findByImageKey(String imageKey);
}
Loading