Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -21,6 +21,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
Expand Down Expand Up @@ -78,4 +80,16 @@ public void deleteFoodTruckImagesFromS3(Long ownerId, Long foodTruckId, DeleteFo

foodTruckImageService.deleteFoodTruckImagesFromS3(owner, foodTruckId, request);
}

public FoodTruckDetailResponse getFoodTruckDetails(Long memberId, Long foodTruckId) {
User member = memberValidator.validateAndGetMember(memberId);

return foodTruckInfoService.getFoodTruckDetails(member, foodTruckId);
}

public List<FoodTruckMenuResponse> searchFoodTruckMenus(Long foodTruckId, String keyword, Long memberId) {
User member = memberValidator.validateAndGetMember(memberId);

return foodTruckMenuService.searchFoodTruckMenus(foodTruckId, keyword, member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import konkuk.chacall.domain.foodtruck.presentation.dto.request.DateRangeRequest;
import konkuk.chacall.domain.foodtruck.presentation.dto.request.FoodTruckSearchRequest;
import konkuk.chacall.domain.foodtruck.presentation.dto.request.UpdateFoodTruckInfoRequest;
import konkuk.chacall.domain.foodtruck.presentation.dto.response.FoodTruckDetailResponse;
import konkuk.chacall.domain.foodtruck.presentation.dto.response.FoodTruckResponse;
import konkuk.chacall.domain.member.domain.repository.SavedFoodTruckRepository;
import konkuk.chacall.domain.region.domain.model.Region;
Expand Down Expand Up @@ -133,4 +134,24 @@ private void syncServiceAreas(FoodTruck foodTruck, Set<Long> requestedRegionIds)
foodTruckServiceAreaRepository.deleteAll(serviceAreasToRemove);
}
}

public FoodTruckDetailResponse getFoodTruckDetails(User member, Long foodTruckId) {
FoodTruck foodTruck = foodTruckRepository.findById(foodTruckId)
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.FOOD_TRUCK_NOT_FOUND));

foodTruck.validateApprovedStatus();

switch(member.getRole()) {
case MEMBER -> foodTruck.validateViewableStatusForMember();
case OWNER -> foodTruck.validateViewableStatusForOwner(member.getUserId());
case ADMIN -> {}
}

List<FoodTruckServiceArea> foodTruckServiceAreas = foodTruckServiceAreaRepository.findAllByFoodTruckId(foodTruckId);
List<AvailableDate> availableDates = availableDateRepository.findAllByFoodTruckId(foodTruckId);

boolean isSaved = savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(member.getUserId(), foodTruckId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

SavedFoodTruckRepository의 버그로 인해 이 코드가 실패합니다.

existsByMemberIdAndFoodTruckId 메서드에 파라미터 이름 불일치 버그가 있어, 이 줄이 실행될 때 런타임 오류가 발생합니다. SavedFoodTruckRepository.java 파일의 해당 메서드를 먼저 수정해야 합니다.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/foodtruck/application/info/FoodTruckInfoService.java
around line 151, the call to
savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(member.getUserId(),
foodTruckId) fails at runtime because SavedFoodTruckRepository has a
parameter-name mismatch bug; open
src/main/java/konkuk/chacall/domain/foodtruck/repository/SavedFoodTruckRepository.java,
fix the method signature/parameter names to match memberId and foodTruckId (and
any @Param annotations if using JPQL/Query), rebuild so the
existsByMemberIdAndFoodTruckId method accepts the correct parameters and the
runtime error is resolved.


return FoodTruckDetailResponse.from(foodTruck, foodTruckServiceAreas, availableDates, isSaved);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
import konkuk.chacall.domain.foodtruck.domain.model.Menu;
import konkuk.chacall.domain.foodtruck.domain.repository.FoodTruckRepository;
import konkuk.chacall.domain.foodtruck.domain.repository.MenuRepository;
import konkuk.chacall.domain.foodtruck.domain.value.FoodTruckStatus;
import konkuk.chacall.domain.foodtruck.presentation.dto.request.FoodTruckMenuRequest;
import konkuk.chacall.domain.foodtruck.presentation.dto.response.FoodTruckMenuResponse;
import konkuk.chacall.domain.owner.presentation.dto.response.MyFoodTruckMenuResponse;
import konkuk.chacall.domain.user.domain.model.User;
import konkuk.chacall.global.common.dto.CursorPagingRequest;
import konkuk.chacall.global.common.dto.CursorPagingResponse;
import konkuk.chacall.global.common.dto.SortType;
Expand Down Expand Up @@ -48,4 +47,16 @@ public CursorPagingResponse<FoodTruckMenuResponse> getFoodTruckMenus(Long foodTr

return CursorPagingResponse.of(content, FoodTruckMenuResponse::menuId, menuSlice.hasNext());
}

public List<FoodTruckMenuResponse> searchFoodTruckMenus(Long foodTruckId, String keyword, User member) {
if(!foodTruckRepository.existsById(foodTruckId)) {
throw new EntityNotFoundException(ErrorCode.FOOD_TRUCK_NOT_FOUND);
}

List<Menu> menus = menuRepository.searchByKeyword(foodTruckId, keyword);

return menus.stream()
.map(FoodTruckMenuResponse::from)
.toList();
}
Comment on lines +51 to +61
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

PR 목표에 명시된 역할별 유효성 검증 로직이 누락되었습니다.

PR 설명에 따르면 다음과 같은 역할별 검증이 필요합니다:

  • 일반 유저: 푸드트럭 노출 상태가 ON인 경우에만 조회 가능
  • 사장님: 본인 소유 푸드트럭이면 노출 상태와 상관없이 조회 가능
  • 관리자: 제한 없음

현재 구현은 member 파라미터를 받지만 전혀 사용하지 않으며, 푸드트럭 노출 상태 및 소유권 검증이 없습니다. getFoodTruckDetails 메서드와 유사한 역할별 검증 로직이 필요합니다.

다음과 같이 수정하세요:

 public List<FoodTruckMenuResponse> searchFoodTruckMenus(Long foodTruckId, String keyword, User member) {
-    if(!foodTruckRepository.existsById(foodTruckId)) {
-        throw new EntityNotFoundException(ErrorCode.FOOD_TRUCK_NOT_FOUND);
-    }
+    FoodTruck foodTruck = foodTruckRepository.findById(foodTruckId)
+            .orElseThrow(() -> new EntityNotFoundException(ErrorCode.FOOD_TRUCK_NOT_FOUND));
+
+    foodTruck.validateApprovedStatus();
+
+    switch(member.getRole()) {
+        case MEMBER -> foodTruck.validateViewableStatusForMember();
+        case OWNER -> foodTruck.validateViewableStatusForOwner(member.getUserId());
+        case ADMIN -> {}
+    }

     List<Menu> menus = menuRepository.searchByKeyword(foodTruckId, keyword);

     return menus.stream()
             .map(FoodTruckMenuResponse::from)
             .toList();
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/foodtruck/application/menu/FoodTruckMenuService.java
around lines 51 to 61, the method ignores the member parameter and omits
role-based validation: load the FoodTruck entity by id (throw
FOOD_TRUCK_NOT_FOUND if absent), then apply the same role-based access rules as
getFoodTruckDetails — if member is ADMIN allow, if member is OWNER allow only
when member.id equals foodTruck.ownerId, otherwise (regular user) allow only
when foodTruck.displayStatus (or equivalent visibility flag) is ON; if access is
denied throw appropriate exception (e.g., ACCESS_DENIED or
FOOD_TRUCK_NOT_VISIBLE). After validation, query menuRepository.searchByKeyword
and map to responses as before.

}
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,10 @@ public static AvailableDate createAvailableDate(LocalDate startDate, LocalDate e
.foodTruck(foodTruck)
.build();
}

public String formatDate() {
return startAt + " ~ " + endAt;
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ public String getServiceAreas(List<FoodTruckServiceArea> serviceAreaList) {
.collect(Collectors.joining(", "));
}

// 푸드트럭의 운영 기간을 반환해주는 메서드
public List<String> getAvailableDates(List<AvailableDate> availableDateList) {
return availableDateList.stream()
.map(AvailableDate::formatDate)
.collect(Collectors.toList());
}

public void changeViewedStatus(FoodTruckViewedStatus targetViewedStatus) {
if(this.foodTruckViewedStatus == targetViewedStatus) {
throw new DomainRuleException(ErrorCode.INVALID_FOOD_TRUCK_STATUS_TRANSITION);
Expand All @@ -155,4 +162,16 @@ public void changeViewedStatus(FoodTruckViewedStatus targetViewedStatus) {

this.foodTruckViewedStatus = targetViewedStatus;
}

public void validateViewableStatusForMember() {
if (this.foodTruckViewedStatus != FoodTruckViewedStatus.ON) {
throw new DomainRuleException(ErrorCode.FOOD_TRUCK_NOT_VIEWABLE);
}
}

public void validateViewableStatusForOwner(Long userId) {
if(!isOwnedBy(userId)) { // 자신이 소유한 푸드트럭이 아니면
validateViewableStatusForMember();
}
}
Comment on lines +166 to +176
Copy link
Contributor

Choose a reason for hiding this comment

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

검증 로직 따로 뺀 것 좋습니다~

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface AvailableDateRepository extends JpaRepository<AvailableDate, Long> {

@Modifying
@Query("DELETE FROM AvailableDate ad WHERE ad.foodTruck.foodTruckId = :foodTruckId")
void deleteAllByFoodTruckId(@Param("foodTruckId") Long foodTruckId);

@Query("SELECT ad FROM AvailableDate ad WHERE ad.foodTruck.foodTruckId = :foodTruckId")
List<AvailableDate> findAllByFoodTruckId(Long foodTruckId);
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package konkuk.chacall.domain.foodtruck.domain.repository;

import konkuk.chacall.domain.foodtruck.domain.model.Menu;
import konkuk.chacall.domain.foodtruck.domain.value.MenuViewedStatus;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MenuRepository extends JpaRepository<Menu, Long> {
Expand Down Expand Up @@ -70,4 +70,14 @@ Slice<Menu> findVisibleMenusAsc(@Param("foodTruckId") Long foodTruckId,
""")
Optional<Menu> findByMenuIdAndFoodTruckId(@Param("menuId") Long menuId,
@Param("foodTruckId") Long foodTruckId);

@Query("""
select m
from Menu m
where m.foodTruck.foodTruckId = :foodTruckId
and m.name like concat('%', :keyword, '%')
and m.menuViewedStatus = konkuk.chacall.domain.foodtruck.domain.value.MenuViewedStatus.ON
order by m.menuId desc
""")
List<Menu> searchByKeyword(Long foodTruckId, String keyword);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "FoodTruck API", description = "푸드트럭 관련 API")
@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -106,7 +108,7 @@ public BaseResponse<CursorPagingResponse<FoodTruckMenuResponse>> getFoodTruckMen
description = "승인이 완료된 나의 푸드트럭 정보를 기입하거나 수정합니다."
)
@ExceptionDescription(SwaggerResponseDescription.UPDATE_FOOD_TRUCK_INFO)
@PutMapping("{foodTruckId}")
@PutMapping("/{foodTruckId}")
public BaseResponse<FoodTruckIdResponse> updateMyFoodTruckInfo(
@Parameter(description = "푸드트럭 ID", example = "1") @PathVariable final Long foodTruckId,
@Valid @RequestBody final UpdateFoodTruckInfoRequest request,
Expand All @@ -120,7 +122,7 @@ public BaseResponse<FoodTruckIdResponse> updateMyFoodTruckInfo(
description = "S3에서 푸드트럭/메뉴 이미지 객체를 삭제합니다. 사용자가 기존 푸드트럭/메뉴 이미지를 삭제했을 경우 호출해주세요."
)
@ExceptionDescription(SwaggerResponseDescription.DELETE_FOOD_TRUCK_IMAGES)
@DeleteMapping("{foodTruckId}/images")
@DeleteMapping("/{foodTruckId}/images")
public BaseResponse<Void> deleteFoodTruckImagesFromS3(
@Parameter(description = "푸드트럭 ID", example = "1") @PathVariable final Long foodTruckId,
@Valid @RequestBody final DeleteFoodTruckImagesRequest request,
Expand All @@ -130,5 +132,30 @@ public BaseResponse<Void> deleteFoodTruckImagesFromS3(
return BaseResponse.ok(null);
}

@Operation(
summary = "푸드트럭 상세조회",
description = "푸드트럭의 상세 정보를 조회합니다."
)
@ExceptionDescription(SwaggerResponseDescription.GET_FOOD_TRUCK_DETAILS)
@GetMapping("/{foodTruckId}")
public BaseResponse<FoodTruckDetailResponse> getFoodTruckDetails(
@Parameter(description = "푸드트럭 ID", example = "1") @PathVariable final Long foodTruckId,
@Parameter(hidden = true) @UserId final Long memberId
) {
return BaseResponse.ok(foodTruckService.getFoodTruckDetails(memberId, foodTruckId));
}

@Operation(
summary = "푸드트럭 메뉴 검색",
description = "푸드트럭 메뉴를 이름으로 검색합니다."
)
@ExceptionDescription(SwaggerResponseDescription.SEARCH_FOOD_TRUCK_MENUS)
@GetMapping("/{foodTruckId}/menus/search")
public BaseResponse<List<FoodTruckMenuResponse>> searchFoodTruckMenus(
@Parameter(description = "푸드트럭 ID", example = "1") @PathVariable final Long foodTruckId,
@Parameter(description = "검색 키워드", example = "치킨") @RequestParam("keyword") final String keyword,
@Parameter(hidden = true) @UserId final Long memberId
) {
return BaseResponse.ok(foodTruckService.searchFoodTruckMenus(foodTruckId, keyword, memberId));
}
Comment on lines +148 to +160
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

빈 문자열 검증 확인 필요

메뉴 검색 엔드포인트가 잘 구현되어 있으나, keyword 파라미터가 빈 문자열("")로 전달될 경우의 처리를 확인해 주세요.

빈 문자열로 검색 시 의도하지 않은 결과(예: 모든 메뉴 반환)가 발생할 수 있습니다. 필요시 @RequestParam 레벨 또는 서비스 레이어에서 @NotBlank 검증을 추가하는 것을 고려해 주세요.

검증 스크립트로 서비스 레이어에서 키워드 검증이 있는지 확인:


🏁 Script executed:

#!/bin/bash
# Description: Check if keyword validation exists in service layer

# Search for searchFoodTruckMenus method implementation
ast-grep --pattern $'searchFoodTruckMenus($$$) {
  $$$
}'

# Also check for blank validation patterns
rg -nP --type=java -A10 'searchFoodTruckMenus.*keyword' -g '!**/test/**'

Length of output: 2621


키워드 빈 문자열 검증 필요

코드 검토 결과, searchFoodTruckMenus 메서드에서 keyword 파라미터가 빈 문자열("")로 전달될 경우 SQL LIKE 쿼리가 모든 메뉴 레코드를 반환할 수 있습니다.

현재 구현:

  • Controller (159줄): 검증 없이 keyword를 서비스로 전달
  • FoodTruckService (90-94줄): memberId만 검증, 바로 위임
  • FoodTruckMenuService (51-61줄): menuRepository.searchByKeyword(foodTruckId, keyword) 직접 호출

해결 방안
Controller의 @RequestParam@NotBlank 추가하거나 서비스 레이어에서 빈 문자열 체크를 구현하세요.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/foodtruck/presentation/FoodTruckController.java
around lines 148-160, the keyword RequestParam is not validated so an empty
string can lead to unintended SQL LIKE results; add validation to reject blank
keywords (e.g., annotate the controller method/parameter with @NotBlank and
ensure the controller class is @Validated) or perform a manual check (trim the
keyword, if blank throw a 400 BadRequest with a clear message) before calling
the service so only non-empty search terms are passed to FoodTruckService.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package konkuk.chacall.domain.foodtruck.presentation.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import konkuk.chacall.domain.foodtruck.domain.model.AvailableDate;
import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck;
import konkuk.chacall.domain.foodtruck.domain.model.FoodTruckServiceArea;
import konkuk.chacall.domain.foodtruck.domain.value.FoodTruckInfo;

import java.util.List;

public record FoodTruckDetailResponse(
@Schema(description = "푸드트럭 식별자", example = "1")
Long foodTruckId,
@Schema(description = "푸드트럭 이름", example = "푸드트럭")
String name,
@Schema(description = "푸드트럭 설명", example = "맛있는 푸드트럭입니다.")
String description,
@Schema(description = "푸드트럭 전화번호", example = "010-1234-5678")
String phoneNumber,
@Schema(description = "푸드트럭 활동 시간", example = "09:00-20:00")
String activeTime,
@Schema(description = "시간 협의 필요 여부", example = "false")
Boolean timeDiscussRequired,
@Schema(description = "호출 가능 지역", example = "서울 광진구, 서울 강남구, 서울 영등포구")
String serviceAreas,
@Schema(description = "푸드트럭 메뉴 카테고리 (라벨 리스트)", example = "[\"한식\",\"분식\"]")
List<String> menuCategories,
@Schema(description = "푸드트럭 제공 가능 수량", example = "200인분 미만")
String availableQuantity,
@Schema(description = "전기 사용 필요 여부", example = "필요")
String needElectricity,
@Schema(description = "결제 방법", example = "무관")
String paymentMethod,
@Schema(description = "푸드트럭 제공 가능 날짜 리스트", example = "[\"2025-10-01 ~ 2025-10-10\",\"2025-11-01 ~ 2025-11-10\"]")
List<String> availableDates,
@Schema(description = "푸드트럭 사진 URL 리스트", example = "[\"http://image.png\",\"http://image2.png\",\"http://image3.png\"]")
List<String> photoUrl,
@Schema(description = "운영 정보", example = "운영정보")
String operatingInfo,
@Schema(description = "추가 옵션 정보", example = "안녕하세요")
String option,
@Schema(description = "푸드트럭 평균 평점", example = "4.5")
Double averageRating,
@Schema(description = "현재 사용자가 저장한 푸드트럭인지 여부", example = "true")
Boolean isSaved
) {
public static FoodTruckDetailResponse from(FoodTruck foodTruck, List<FoodTruckServiceArea> serviceAreas, List<AvailableDate> availableDates, Boolean isSaved) {
FoodTruckInfo foodTruckInfo = foodTruck.getFoodTruckInfo();
return new FoodTruckDetailResponse(
foodTruck.getFoodTruckId(),
foodTruckInfo.getName(),
foodTruckInfo.getDescription(),
foodTruckInfo.getPhoneNumber(),
foodTruckInfo.getActiveTime(),
foodTruckInfo.getTimeDiscussRequired(),
foodTruck.getServiceAreas(serviceAreas),
foodTruckInfo.getMenuCategoryList().getMenuCategoryLabelList(),
foodTruckInfo.getAvailableQuantity().getValue(),
foodTruckInfo.getNeedElectricity().getValue(),
foodTruckInfo.getPaymentMethod().getValue(),
foodTruck.getAvailableDates(availableDates),
foodTruckInfo.getFoodTruckPhotoList().getUrls(),
foodTruckInfo.getOperatingInfo(),
foodTruckInfo.getOption(),
foodTruck.getRatingInfo().getAverageRating(),
isSaved
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,14 @@ Slice<SavedFoodTruck> findMemberSavedFoodTruckWithCursor(
Set<Long> findSavedTruckIdsIn(@Param("userId") Long userId,
@Param("foodTruckIds") List<Long> foodTruckIds);

@Query("""
select exists (
select s
from SavedFoodTruck s
where s.member.userId = :userId
and s.foodTruck.foodTruckId = :foodTruckId
)
""")
boolean existsByMemberIdAndFoodTruckId(@Param("userId") Long userId,
@Param("foodTruckId") Long foodTruckId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum ErrorCode implements ResponseCode {
AVAILABLE_QUANTITY_MISMATCH(HttpStatus.BAD_REQUEST, 110007, "제조 가능 수량 값이 올바르지 않습니다."),
NEED_ELECTRICITY_MISMATCH(HttpStatus.BAD_REQUEST, 110008, "전기 사용 여부 값이 올바르지 않습니다."),
PAYMENT_METHOD_MISMATCH(HttpStatus.BAD_REQUEST, 110009, "결제 수단 값이 올바르지 않습니다."),
FOOD_TRUCK_NOT_VIEWABLE(HttpStatus.FORBIDDEN, 110010, "노출 가능한 상태의 푸드트럭이 아닙니다."),

/**
* Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ public enum SwaggerResponseDescription {
FOOD_TRUCK_NOT_FOUND,
FOOD_TRUCK_NOT_OWNED
))),
GET_FOOD_TRUCK_DETAILS(new LinkedHashSet<>(Set.of(
USER_NOT_FOUND,
USER_FORBIDDEN,
FOOD_TRUCK_NOT_FOUND,
FOOD_TRUCK_NOT_OWNED,
FOOD_TRUCK_NOT_VIEWABLE,
FOOD_TRUCK_NOT_APPROVED
))),
SEARCH_FOOD_TRUCK_MENUS(new LinkedHashSet<>(Set.of(
USER_NOT_FOUND,
USER_FORBIDDEN,
FOOD_TRUCK_NOT_FOUND
))),

// Default
DEFAULT(new LinkedHashSet<>())
Expand Down