Skip to content
Merged

Test #83

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
6e140e0
메뉴_이미지_파일_업로드_기능_추가 : feat : {S3Service - upload 기능 구현} https://githu…
kjh0718 Jan 25, 2026
a340bb4
메뉴_이미지_파일_업로드_기능_추가 : feat : {S3 upload 기능 test 삭제} https://github.co…
kjh0718 Jan 25, 2026
2a231f8
메뉴_이미지_파일_업로드_기능_추가 : feat : {menu image 파일 업로드 기능 구현} https://github…
kjh0718 Jan 25, 2026
8430fef
메뉴_이미지_파일_업로드_기능_추가 : feat : {메뉴 이미지 해당 식당별 경로 생성} https://github.com…
kjh0718 Jan 26, 2026
6ebcd32
메뉴_이미지_파일_업로드_기능_추가 : feat : {메뉴 생성시 이미지 추가 기능 + docs 수정} https://git…
kjh0718 Jan 26, 2026
57dfdbc
메뉴_이미지_파일_업로드_기능_추가 : feat : {s3 deletefile 기능 구현} https://github.com…
kjh0718 Jan 27, 2026
06ad745
메뉴_이미지_파일_업로드_기능_추가 : feat : {menu 관리자 전용 API 경로 분리} https://github.c…
kjh0718 Jan 27, 2026
c2f800f
메뉴_이미지_파일_업로드_기능_추가 : feat : {spring-cloud-aws-starter-s3 버전 3.1.0 ->…
kjh0718 Feb 1, 2026
3f38909
메뉴_이미지_파일_업로드_기능_추가 : feat : {crateMenu:이미지포함 생성시 이미지 url까지 반환,기존 이미지…
kjh0718 Feb 1, 2026
15d0e12
메뉴_이미지_파일_업로드_기능_추가 : feat : {메뉴 저장 실패시 이미지 삭제} https://github.com/Ca…
kjh0718 Feb 1, 2026
202ca11
메뉴_이미지_파일_업로드_기능_추가 : feat : {로그 수정} https://github.com/CampusTable/c…
kjh0718 Feb 1, 2026
eb1cf7a
Merge pull request #82 from CampusTable/20260125_#76_메뉴_이미지_파일_업로드_기능_추가
kjh0718 Feb 1, 2026
585659a
메뉴_이미지_파일_업로드_기능_추가 : feat : {s3 환경변수} https://github.com/CampusTable…
kjh0718 Feb 2, 2026
c813338
메뉴_이미지_파일_업로드_기능_추가 : feat : {createMenu: save -> saveAndFlush, uploa…
kjh0718 Feb 2, 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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ dependencies {

// JavaNetCookieJar
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.3'

// S3
implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.4.2')

implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/menu")
@RequestMapping("/api")
@RequiredArgsConstructor
public class MenuController implements MenuControllerDocs {

Expand All @@ -23,7 +25,7 @@ public class MenuController implements MenuControllerDocs {


@Override
@GetMapping
@GetMapping("/menus")
@LogMonitoringInvocation
public ResponseEntity<List<MenuResponse>> getAllMenus(){

Expand All @@ -35,7 +37,7 @@ public ResponseEntity<List<MenuResponse>> getAllMenus(){

@Override
@LogMonitoringInvocation
@GetMapping("/category/{category_id}")
@GetMapping("/category/{category_id}/menus")
public ResponseEntity<List<MenuResponse>> getAllMenusByCategoryId(
@PathVariable(name = "category_id") Long categoryId){

Expand All @@ -47,31 +49,46 @@ public ResponseEntity<List<MenuResponse>> getAllMenusByCategoryId(

@Override
@LogMonitoringInvocation
@GetMapping("/{menuId}")
@GetMapping("/menus/{menuId}")
public ResponseEntity<MenuResponse> getMenuById(@PathVariable Long menuId){
return ResponseEntity.ok(menuService.getMenuById(menuId));
}

@Override
@LogMonitoringInvocation
@GetMapping("/cafeteria/{cafeteria-id}")
@GetMapping("/menus/cafeteria/{cafeteria-id}")
public ResponseEntity<List<MenuResponse>> getAllMenusByCafeteriaId(
@PathVariable(name = "cafeteria-id") Long cafeteriaId
) {
return ResponseEntity.ok(menuService.getAllMenusByCafeteriaId(cafeteriaId));
}

@Override
@PostMapping
@PostMapping(value = "/admin/menus", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogMonitoringInvocation
public ResponseEntity<MenuResponse> createMenu(@Valid @RequestBody MenuRequest createRequest){
MenuResponse createMenu = menuService.createMenu(createRequest);
public ResponseEntity<MenuResponse> createMenu(
@Valid @ModelAttribute MenuRequest request
){
MenuResponse createMenu = menuService.createMenu(request, request.getImage());

return ResponseEntity.status(HttpStatus.CREATED).body(createMenu);
}

@Override
@PatchMapping("/{menu_id}")
@PostMapping(value = "/admin/menus/{menu_id}/image" ,consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogMonitoringInvocation
public ResponseEntity<MenuResponse> uploadMenuImage(
@PathVariable(name = "menu_id") Long menuId,
@RequestParam("image") MultipartFile image){

MenuResponse response = menuService.uploadMenuImage(menuId, image);

return ResponseEntity.ok(response);

}

@Override
@PatchMapping("/admin/menus/{menu_id}")
@LogMonitoringInvocation
public ResponseEntity<MenuResponse> updateMenu(
@PathVariable(name = "menu_id") Long menuId,
Expand All @@ -84,7 +101,7 @@ public ResponseEntity<MenuResponse> updateMenu(

@Override
@LogMonitoringInvocation
@DeleteMapping("/{menu_id}")
@DeleteMapping("/admin/menus/{menu_id}")
public ResponseEntity<Void> deleteMenu(
@PathVariable(name = "menu_id") Long menuId) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.http.ResponseEntity;

import java.util.List;
import org.springframework.web.multipart.MultipartFile;

/**
* 메뉴 관리 시스템의 API 명세를 정의하는 인터페이스입니다.
Expand All @@ -24,16 +25,13 @@ public interface MenuControllerDocs {

/**
* 시스템에 등록된 모든 메뉴 목록을 조회합니다.
* * @return 메뉴 정보 리스트를 담은 ResponseEntity
*/
@Operation(summary = "메뉴 전체 조회", description = "모든 메뉴 목록을 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
ResponseEntity<List<MenuResponse>> getAllMenus();

/**
* 고유 식별자를 통해 단일 메뉴의 상세 정보를 조회합니다.
* * @param menuId 조회하고자 하는 메뉴의 ID
* @return 해당 메뉴의 상세 정보를 담은 ResponseEntity
*/
@Operation(summary = "단일 메뉴 상세 조회", description = "특정 ID에 해당하는 메뉴의 상세 정보를 조회합니다.")
@ApiResponses({
Expand All @@ -47,8 +45,6 @@ ResponseEntity<MenuResponse> getMenuById(

/**
* 특정 카테고리에 속한 모든 메뉴를 조회합니다.
* * @param categoryId 카테고리 고유 식별자
* @return 해당 카테고리의 메뉴 리스트를 담은 ResponseEntity
*/
@Operation(summary = "카테고리별 메뉴 조회", description = "특정 카테고리 ID에 해당하는 메뉴 목록을 조회합니다.")
@ApiResponses({
Expand All @@ -62,8 +58,6 @@ ResponseEntity<List<MenuResponse>> getAllMenusByCategoryId(

/**
* 특정 식당에서 제공하는 모든 메뉴를 조회합니다.
* * @param cafeteriaId 식당 고유 식별자
* @return 해당 식당의 메뉴 리스트를 담은 ResponseEntity
*/
@Operation(summary = "식당별 메뉴 조회", description = "식당 ID에 해당하는 메뉴 목록을 조회합니다.")
@ApiResponses({
Expand All @@ -77,7 +71,7 @@ ResponseEntity<List<MenuResponse>> getAllMenusByCafeteriaId(

/**
* 새로운 메뉴를 시스템에 등록합니다. (관리자 권한 필요)
* * @param menuRequest 생성할 메뉴의 상세 정보 DTO
* * @param request 생성할 메뉴의 상세 정보 DTO + 이미지 파일
* @return 생성된 메뉴 정보를 담은 ResponseEntity
*/
@Operation(summary = "신규 메뉴 생성 (관리자 전용)", description = "새로운 메뉴를 등록합니다.")
Expand All @@ -88,15 +82,36 @@ ResponseEntity<List<MenuResponse>> getAllMenusByCafeteriaId(
@ApiResponse(responseCode = "409", description = "이미 존재하는 메뉴입니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
ResponseEntity<MenuResponse> createMenu(MenuRequest menuRequest);
ResponseEntity<MenuResponse> createMenu(
@Parameter(description = "메뉴 정보 및 이미지 파일") MenuRequest request
);

/**
* 메뉴에 이미지를 업로드 합니다. (관리자 권한 필요)
* * @param menuId 이미지를 등록할 메뉴의 ID
* @param image 업로드할 이미지 파일
* @return 이미지 업로드가 완료된 메뉴 정보를 담은 ResponseEntity
*/
@Operation(
summary = "메뉴 이미지 개별 업로드/수정 (관리자 전용)",
description = "메뉴의 사진을 추가하거나 수정합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "이미지 업로드 및 경로 업데이트 성공"),
@ApiResponse(responseCode = "404", description = "해당 ID의 메뉴를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "400", description = "유효하지 않은 파일 요청입니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
ResponseEntity<MenuResponse> uploadMenuImage(
@Parameter(description = "대상 메뉴 ID", example = "1") Long menuId,
@Parameter(description = "업로드할 이미지 파일") MultipartFile image
);

/**
* 기존 메뉴 정보를 수정합니다. (관리자 권한 필요)
* * @param menuId 수정할 메뉴의 ID
* @param menuUpdateRequest 수정할 내용이 담긴 DTO
* @return 수정 완료된 메뉴 정보를 담은 ResponseEntity
*/
@Operation(summary = "메뉴 정보 수정 (관리자 전용)", description = "특정 ID의 메뉴 정보를 수정합니다.")
@Operation(summary = "메뉴 정보 수정 (관리자 전용)", description = "특정 ID의 메뉴 정보를 수정합니다. (이미지 제외)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "메뉴 수정 성공"),
@ApiResponse(responseCode = "400", description = "입력값 오류",
Expand All @@ -106,13 +121,11 @@ ResponseEntity<List<MenuResponse>> getAllMenusByCafeteriaId(
})
ResponseEntity<MenuResponse> updateMenu(
@Parameter(description = "수정할 메뉴 ID", example = "1") Long menuId,
MenuUpdateRequest menuUpdateRequest
@Parameter(description = "수정할 메뉴 정보") MenuUpdateRequest menuUpdateRequest
);

/**
* 특정 메뉴를 시스템에서 삭제합니다. (관리자 권한 필요)
* * @param menuId 삭제할 메뉴의 ID
* @return 삭제 성공 시 빈 바디를 담은 ResponseEntity (204 No Content)
*/
@Operation(summary = "메뉴 삭제 (관리자 전용)", description = "특정 ID의 메뉴를 삭제합니다.")
@ApiResponses({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.math.BigDecimal;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

@Getter
@Setter
Expand All @@ -26,21 +27,21 @@ public class MenuRequest {
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
private Integer price;

@NotBlank(message = "이미지를 위한url은 필수입니다.")
private String menuUrl;

@NotNull(message = "판매 가능 여부는 필수입니다.")
private Boolean available;

private Integer stockQuantity;

private MultipartFile image;


public Menu toEntity(Category category) {
return Menu.builder()
.category(category)
.menuName(this.getMenuName())
.price(this.getPrice())
.menuUrl(this.getMenuUrl())
.menuUrl(null)
.available(this.getAvailable())
.stockQuantity(this.getStockQuantity())
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.campustable.be.domain.menu.dto.MenuUpdateRequest;
import com.campustable.be.domain.menu.entity.Menu;
import com.campustable.be.domain.menu.repository.MenuRepository;
import com.campustable.be.domain.s3.service.S3Service;
import com.campustable.be.global.exception.CustomException;
import com.campustable.be.global.exception.ErrorCode;
import java.util.ArrayList;
Expand All @@ -19,6 +20,7 @@
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import org.springframework.web.multipart.MultipartFile;


@Slf4j
Expand All @@ -29,37 +31,79 @@ public class MenuService {
private final MenuRepository menuRepository;
private final CategoryRepository categoryRepository;
private final CafeteriaService cafeteriaService;
private final S3Service s3Service;


@Transactional
public MenuResponse createMenu(MenuRequest request) {
public MenuResponse createMenu(MenuRequest request, MultipartFile image) {

Category category = categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> {
log.warn("createMenu: 유효하지 않은 category id");
return new CustomException(ErrorCode.CATEGORY_NOT_FOUND);
});

Optional<Menu> existingMenu = menuRepository.findByCategoryAndMenuName(
category,
request.getMenuName()
);
menuRepository.findByCategoryAndMenuName(category, request.getMenuName())
.ifPresent(menu -> {
log.error("createMenu: 이미 해당 카테고리에 menu가 존재합니다. 생성이 아닌 수정을 통해 진행해주세요.");
throw new CustomException(ErrorCode.MENU_ALREADY_EXISTS);
});

Menu menu = request.toEntity(category);
Menu savedMenu = menuRepository.save(menu);

if (existingMenu.isPresent()) {
log.error("createMenu: 이미 해당 카테고리에 menu가 존재합니다. 생성이 아닌 수정을 통해 진행해주세요.");
throw new CustomException(ErrorCode.MENU_ALREADY_EXISTS);
if (image != null && !image.isEmpty()) {
return uploadMenuImage(savedMenu.getId(), image);
}

Menu menu = request.toEntity(category);
return MenuResponse.from(menuRepository.save(menu));
return MenuResponse.from(savedMenu);

}

@Transactional
public MenuResponse uploadMenuImage(Long menuId, MultipartFile image) {
Menu menu = menuRepository.findById(menuId)
.orElseThrow(() -> new CustomException(ErrorCode.MENU_NOT_FOUND));

if (image == null || image.isEmpty()) {
throw new CustomException(ErrorCode.INVALID_FILE_REQUEST);
}

String oldUrl = menu.getMenuUrl();
String cafeteriaName = menu.getCategory().getCafeteria().getName();
String dirName = "menu/" + cafeteriaName;

String newUrl = s3Service.uploadFile(image, dirName);
menu.setMenuUrl(newUrl);
Menu savedMenu;
try {
savedMenu = menuRepository.saveAndFlush(menu);
} catch (Exception e) {
try {
s3Service.deleteFile(newUrl);
} catch (Exception ex) {
log.warn("uploadMenuImage: 신규 이미지 정리 실패. newUrl={}", newUrl, ex);
}
log.error("uploadMenuImage: 메뉴 저장 실패. menuId={}", menuId, e);
throw e;
}

if (oldUrl != null && !oldUrl.isBlank()) {
try {
s3Service.deleteFile(oldUrl);
} catch (Exception e) {
log.warn("uploadMenuImage: 기존 이미지 삭제 실패. oldUrl={}", oldUrl, e);
}
}

return MenuResponse.from(savedMenu);
}

@Transactional(readOnly = true)
public MenuResponse getMenuById(Long menuId) {

Menu menu = menuRepository.findById(menuId)
.orElseThrow(()->{
.orElseThrow(() -> {
log.error("getMenuById : 유효하지않은 menuId");
return new CustomException(ErrorCode.MENU_NOT_FOUND);
});
Expand Down Expand Up @@ -137,9 +181,15 @@ public void deleteMenu(Long menuId) {
if (menu.isEmpty()) {
log.error("menuId not found {}", menuId);
throw new CustomException(ErrorCode.MENU_NOT_FOUND);
} else {
menuRepository.delete(menu.get());
}
if (menu.get().getMenuUrl() != null && !menu.get().getMenuUrl().isBlank()) {
try {
s3Service.deleteFile(menu.get().getMenuUrl());
} catch (Exception e) {
log.warn("deleteMenu: 이미지 삭제 실패. menuId={}, url={}", menuId, menu.get().getMenuUrl(), e);
}
}
menuRepository.deleteById(menuId);
Comment on lines +185 to +192
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

S3 삭제와 DB 삭제 순서 조정 필요

현재 S3 파일을 삭제한 후 DB 레코드를 삭제합니다. deleteById()가 실패할 경우 (예: 외래키 제약 위반), S3 파일은 이미 삭제되었지만 메뉴 레코드는 유효하지 않은 URL을 가진 채로 남게 됩니다.

DB 삭제를 먼저 수행하고, 성공 시 S3 파일을 삭제하는 것이 안전합니다.

수정 제안
  `@Transactional`
  public void deleteMenu(Long menuId) {

    Optional<Menu> menu = menuRepository.findById(menuId);
    if (menu.isEmpty()) {
      log.error("menuId not found {}", menuId);
      throw new CustomException(ErrorCode.MENU_NOT_FOUND);
    }
+   String menuUrl = menu.get().getMenuUrl();
+   menuRepository.deleteById(menuId);
+   
+   if (menuUrl != null && !menuUrl.isBlank()) {
-   if (menu.get().getMenuUrl() != null && !menu.get().getMenuUrl().isBlank()) {
      try {
-       s3Service.deleteFile(menu.get().getMenuUrl());
+       s3Service.deleteFile(menuUrl);
      } catch (Exception e) {
-       log.warn("deleteMenu: 이미지 삭제 실패. menuId={}, url={}", menuId, menu.get().getMenuUrl(), e);
+       log.warn("deleteMenu: 이미지 삭제 실패. menuId={}, url={}", menuId, menuUrl, e);
      }
    }
-   menuRepository.deleteById(menuId);
  }
🤖 Prompt for AI Agents
In `@src/main/java/com/campustable/be/domain/menu/service/MenuService.java` around
lines 185 - 192, The current flow deletes the S3 file via
s3Service.deleteFile(menu.get().getMenuUrl()) before removing the DB row with
menuRepository.deleteById(menuId), which can leave the DB pointing at a
now-missing S3 object if deleteById fails; change the sequence to (1) capture
the menu URL into a local variable (e.g., String menuUrl =
menu.get().getMenuUrl()) so you have it after the DB operation, (2) call
menuRepository.deleteById(menuId) first, and only if that succeeds attempt
s3Service.deleteFile(menuUrl) inside a try/catch, logging failures with
log.warn("deleteMenu: 이미지 삭제 실패. menuId={}, url={}", menuId, menuUrl, e) so S3
errors don’t block DB deletion.

}

}
Expand Down
Loading