diff --git a/src/main/java/com/acon/server/admin/api/controller/AdminController.java b/src/main/java/com/acon/server/admin/api/controller/AdminController.java index 39e2da4..6905afc 100644 --- a/src/main/java/com/acon/server/admin/api/controller/AdminController.java +++ b/src/main/java/com/acon/server/admin/api/controller/AdminController.java @@ -1,6 +1,7 @@ package com.acon.server.admin.api.controller; import com.acon.server.admin.api.request.CreateSpotRequest; +import com.acon.server.admin.api.request.UpdateSpotDetailRequest; import com.acon.server.admin.api.response.AdminSpotDetailResponse; import com.acon.server.admin.api.response.CsrfTokenResponse; import com.acon.server.admin.api.response.DashboardResponse; @@ -113,12 +114,16 @@ public ResponseEntity getSpotDetail( ); } - @PatchMapping(path = "/spots/{spotId}", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) + @PatchMapping(path = "/spots/{spotId}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity updateSpotDetail( - @PathVariable Long spotId, - @RequestBody Map updateRequest) { + @NotNull(message = "spotId는 필수입니다.") + @Positive(message = "spotId는 양수여야 합니다.") + @PathVariable final Long spotId, + + @Valid @RequestBody final UpdateSpotDetailRequest request + ) { + adminService.updateSpotDetail(spotId, request); + return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/acon/server/admin/api/request/UpdateSpotDetailRequest.java b/src/main/java/com/acon/server/admin/api/request/UpdateSpotDetailRequest.java new file mode 100644 index 0000000..0d2c3e5 --- /dev/null +++ b/src/main/java/com/acon/server/admin/api/request/UpdateSpotDetailRequest.java @@ -0,0 +1,103 @@ +package com.acon.server.admin.api.request; + +import com.acon.server.global.exception.BusinessException; +import com.acon.server.global.exception.ErrorType; +import com.acon.server.spot.domain.enums.SpotType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; +import org.springframework.util.StringUtils; + +public record UpdateSpotDetailRequest( + @Size(min = 1, max = 20, message = "spotName은 1자 이상 20자 이하이어야 합니다.") + String spotName, + + @Size(min = 1, max = 100, message = "address는 1자 이상 100자 이하이어야 합니다.") + String address, + + @Min(value = 0, message = "localAcornCount는 0 이상이어야 합니다.") + Integer localAcornCount, + + @Min(value = 0, message = "basicAcornCount는 0 이상이어야 합니다.") + Integer basicAcornCount, + + SpotType spotType, + + List<@NotBlank(message = "spotFeature는 공백일 수 없습니다.") String> spotFeatureList, + + @Size(min = 7, max = 7, message = "영업 시간은 7일 모두 입력해야 합니다.") + List<@Valid OpeningHourItem> openingHourList, + + @Size(min = 1, message = "대표 메뉴는 최소 1개 이상 입력해야 합니다.") + List<@Valid SignatureMenu> signatureMenuList, + + String priceFeature, + + @Size(max = 10, message = "image는 최대 10장까지 업로드 가능합니다.") + List<@NotBlank(message = "imageUrl은 공백일 수 없습니다.") String> menuboardImageList, + + @Size(max = 10, message = "image는 최대 10장까지 업로드 가능합니다.") + List<@NotBlank(message = "imageUrl은 공백일 수 없습니다.") String> spotImageList +) { + + // Restaurant로 변경하는 경우 spotFeatureList와 priceFeature 필수 검증 + public UpdateSpotDetailRequest { + if (spotType == SpotType.RESTAURANT) { + if (spotFeatureList == null || spotFeatureList.isEmpty()) { + throw new BusinessException(ErrorType.MISSING_REQUIRED_FIELDS_ERROR); + } + + if (!StringUtils.hasText(priceFeature)) { + throw new BusinessException(ErrorType.MISSING_REQUIRED_FIELDS_ERROR); + } + } + } + + public record OpeningHourItem( + @NotNull(message = "dayOfWeek는 필수입니다.") + DayOfWeek dayOfWeek, + + @NotNull(message = "closed는 필수입니다.") + Boolean closed, + + LocalTime startTime, + + LocalTime endTime, + + LocalTime breakStartTime, + + LocalTime breakEndTime + ) { + + public OpeningHourItem { + if (!Boolean.TRUE.equals(closed)) { + // 휴무일이 아닌 경우 startTime과 endTime 필수 검증 + if (startTime == null || endTime == null) { + throw new BusinessException(ErrorType.MISSING_REQUIRED_FIELDS_ERROR); + } + + // break time 검증 (둘 다 있거나 둘 다 없어야 함) + if ((breakStartTime == null && breakEndTime != null) || + (breakStartTime != null && breakEndTime == null)) { + throw new BusinessException(ErrorType.MISSING_REQUIRED_FIELDS_ERROR); + } + } + } + } + + public record SignatureMenu( + @NotBlank(message = "name은 공백일 수 없습니다.") + String name, + + @NotNull(message = "price는 필수입니다.") + @Min(value = -1, message = "price는 -1 이상이어야 합니다.") + Integer price + ) { + + } +} diff --git a/src/main/java/com/acon/server/admin/application/service/AdminService.java b/src/main/java/com/acon/server/admin/application/service/AdminService.java index ae73910..826a683 100644 --- a/src/main/java/com/acon/server/admin/application/service/AdminService.java +++ b/src/main/java/com/acon/server/admin/application/service/AdminService.java @@ -1,6 +1,7 @@ package com.acon.server.admin.application.service; import com.acon.server.admin.api.request.CreateSpotRequest; +import com.acon.server.admin.api.request.UpdateSpotDetailRequest; import com.acon.server.admin.api.response.AdminSpotDetailResponse; import com.acon.server.admin.api.response.DashboardResponse; import com.acon.server.admin.api.response.SpotListResponse; @@ -16,6 +17,10 @@ import com.acon.server.member.infra.entity.MemberEntity; import com.acon.server.member.infra.repository.MemberRepository; import com.acon.server.review.infra.repository.ReviewRepository; +import com.acon.server.spot.application.mapper.OpeningHourMapper; +import com.acon.server.spot.application.mapper.SpotMapper; +import com.acon.server.spot.domain.entity.OpeningHour; +import com.acon.server.spot.domain.entity.Spot; import com.acon.server.spot.domain.enums.SpotStatus; import com.acon.server.spot.domain.enums.SpotType; import com.acon.server.spot.infra.entity.MenuEntity; @@ -58,6 +63,8 @@ public class AdminService { private final SpotImageRepository spotImageRepository; private final ReviewRepository reviewRepository; private final S3Adapter s3Adapter; + private final SpotMapper spotMapper; + private final OpeningHourMapper openingHourMapper; @Transactional(readOnly = true) public DashboardResponse getDashboard() { @@ -359,4 +366,301 @@ public AdminSpotDetailResponse getSpotDetail(final Long spotId) { spotImageList ); } + + @Transactional + public void updateSpotDetail(final Long spotId, final UpdateSpotDetailRequest request) { + // 0. 모든 이미지 유효성 검증 (S3 작업 전에 먼저 수행) + if (request.menuboardImageList() != null) { + for (String imageUrl : request.menuboardImageList()) { + s3Adapter.validateImageExists(imageUrl); + } + } + + if (request.spotImageList() != null) { + for (String imageUrl : request.spotImageList()) { + s3Adapter.validateImageExists(imageUrl); + } + } + + // 1. Spot 존재 확인 및 기본 정보 업데이트 + SpotEntity spotEntity = spotRepository.findByIdOrElseThrow(spotId); + Spot spot = spotMapper.toDomain(spotEntity); + SpotType originalSpotType = spot.getSpotType(); + + // 2. 동일한 장소명과 주소를 가진 활성화된 장소가 있는지 확인 (자기 자신 제외) + if ((request.spotName() != null || request.address() != null) && + spotEntity.getSpotStatus() == SpotStatus.ACTIVE) { + String newName = request.spotName() != null ? request.spotName() : spot.getName(); + String newAddress = request.address() != null ? request.address() : spot.getAddress(); + + boolean exists = spotRepository.existsByNameAndAddressAndSpotStatusAndIdNot( + newName, + newAddress, + SpotStatus.ACTIVE, + spotId + ); + + if (exists) { + throw new BusinessException(ErrorType.DUPLICATE_ACTIVE_SPOT_ERROR); + } + } + + // Spot 기본 정보 업데이트 (null이 아닌 경우만) + if (request.spotName() != null) { + spot.updateName(request.spotName()); + } + + if (request.address() != null) { + spot.updateAddress(request.address()); + } + + if (request.localAcornCount() != null) { + spot.updateLocalAcornCount(request.localAcornCount()); + } + + if (request.basicAcornCount() != null) { + spot.updateBasicAcornCount(request.basicAcornCount()); + } + + if (request.spotType() != null) { + spot.updateSpotType(request.spotType()); + } + + spotRepository.save(spotMapper.toEntity(spot)); + + // 3. spotType 변경 처리 + if (request.spotType() != null && originalSpotType != request.spotType()) { + // 기존 spotOption 삭제 (features와 price) + spotOptionRepository.deleteAllBySpotId(spotId); + } + + // 4. spotFeatureList 업데이트 + if (request.spotFeatureList() != null) { + // 현재 spotType 확인 (request에 있으면 그걸 사용, 없으면 기존 값 사용) + SpotType currentSpotType = request.spotType() != null ? request.spotType() : originalSpotType; + String featureCategoryName = currentSpotType == SpotType.RESTAURANT ? "RESTAURANT_FEATURE" : "CAFE_FEATURE"; + + Long featureCategoryId = categoryRepository.findByNameOrElseThrow(featureCategoryName).getId(); + + // 기존 feature spotOption 삭제 + spotOptionRepository.deleteBySpotIdAndCategoryId(spotId, featureCategoryId); + + // 새로운 feature 추가 + List newFeatures = new ArrayList<>(); + + for (String spotFeature : request.spotFeatureList()) { + OptionEntity optionEntity; + + try { + optionEntity = optionRepository.findByCategoryIdAndNameOrElseThrow(featureCategoryId, spotFeature); + } catch (BusinessException e) { + throw new BusinessException(ErrorType.INVALID_SPOT_FEATURE_ERROR); + } + + newFeatures.add( + SpotOptionEntity.builder() + .spotId(spotId) + .optionId(optionEntity.getId()) + .build() + ); + } + + spotOptionRepository.saveAll(newFeatures); + } + + // 5. priceFeature 업데이트 + if (request.priceFeature() != null) { + Long priceCategoryId = categoryRepository.findByNameOrElseThrow("PRICE").getId(); + + // 기존 price spotOption 삭제 + spotOptionRepository.deleteBySpotIdAndCategoryId(spotId, priceCategoryId); + + // 새로운 price 추가 + OptionEntity optionEntity; + + try { + optionEntity = optionRepository.findByCategoryIdAndNameOrElseThrow( + priceCategoryId, + request.priceFeature() + ); + } catch (BusinessException e) { + throw new BusinessException(ErrorType.INVALID_PRICE_FEATURE_ERROR); + } + + spotOptionRepository.save( + SpotOptionEntity.builder() + .spotId(spotId) + .optionId(optionEntity.getId()) + .build() + ); + } + + // 6. openingHourList 업데이트 + if (request.openingHourList() != null) { + // 기존 영업시간 조회 + List existingHours = openingHourRepository.findAllBySpotId(spotId); + + // 각 요일별로 업데이트 + for (UpdateSpotDetailRequest.OpeningHourItem newHour : request.openingHourList()) { + OpeningHourEntity existingHour = existingHours.stream() + .filter(h -> h.getDayOfWeek() == newHour.dayOfWeek()) + .findFirst() + .orElse(null); + + if (existingHour != null) { + // 기존 엔티티를 도메인으로 변환 후 업데이트 + OpeningHour openingHour = openingHourMapper.toDomain(existingHour); + openingHour.updateClosed(newHour.closed()); + + if (Boolean.TRUE.equals(newHour.closed())) { + // 휴무일인 경우 시간 정보 모두 null + openingHour.updateStartTime(null); + openingHour.updateEndTime(null); + openingHour.updateBreakStartTime(null); + openingHour.updateBreakEndTime(null); + } else { + openingHour.updateStartTime(newHour.startTime()); + openingHour.updateEndTime(newHour.endTime()); + openingHour.updateBreakStartTime(newHour.breakStartTime()); + openingHour.updateBreakEndTime(newHour.breakEndTime()); + } + + openingHourRepository.save(openingHourMapper.toEntity(openingHour)); + } else { + // 새로운 엔티티 생성 + OpeningHourEntity newEntity = OpeningHourEntity.builder() + .spotId(spotId) + .dayOfWeek(newHour.dayOfWeek()) + .closed(newHour.closed()) + .startTime(Boolean.TRUE.equals(newHour.closed()) ? null : newHour.startTime()) + .endTime(Boolean.TRUE.equals(newHour.closed()) ? null : newHour.endTime()) + .breakStartTime(Boolean.TRUE.equals(newHour.closed()) ? null : newHour.breakStartTime()) + .breakEndTime(Boolean.TRUE.equals(newHour.closed()) ? null : newHour.breakEndTime()) + .build(); + + openingHourRepository.save(newEntity); + } + } + } + + // 7. signatureMenuList 업데이트 + if (request.signatureMenuList() != null) { + // 기존 메뉴 모두 삭제 + menuRepository.deleteAllBySpotId(spotId); + + // 새로운 메뉴 추가 + List newMenus = request.signatureMenuList().stream() + .map(menu -> MenuEntity.builder() + .spotId(spotId) + .name(menu.name()) + .price(menu.price()) + .build()) + .toList(); + + menuRepository.saveAll(newMenus); + } + + // 8. menuboardImageList 업데이트 + if (request.menuboardImageList() != null) { + // 기존 메뉴판 이미지 조회 + List existingMenuboardImages = + menuboardImageRepository.findAllBySpotIdOrderById(spotId); + + // 기존 이미지 URL 목록 + List existingImageUrls = existingMenuboardImages.stream() + .map(MenuboardImageEntity::getImage) + .toList(); + + // 삭제된 이미지만 S3에서 삭제 + List deletedImages = existingImageUrls.stream() + .filter(url -> !request.menuboardImageList().contains(url)) + .toList(); + + for (String deletedImage : deletedImages) { + s3Adapter.deleteFile(deletedImage); + } + + // 기존 DB 레코드 모두 삭제 (순서 재정렬을 위해) + menuboardImageRepository.deleteAll(existingMenuboardImages); + + // 새로운 메뉴판 이미지 추가 + if (!request.menuboardImageList().isEmpty()) { + List finalImageUrls = new ArrayList<>(); + + for (String imageUrl : request.menuboardImageList()) { + // 기존 이미지인지 확인 (이미 spot 폴더에 있음) + if (existingImageUrls.contains(imageUrl)) { + finalImageUrls.add(imageUrl); + } else { + // 새 이미지인 경우 temp -> spot 폴더로 이동 + String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + String destinationKey = String.format("spots/%d/menuboard/%s", spotId, fileName); + String newImageUrl = s3Adapter.moveFile(imageUrl, destinationKey); + finalImageUrls.add(newImageUrl); + } + } + + // DB에 저장 + List newMenuboardImages = finalImageUrls.stream() + .map(image -> MenuboardImageEntity.builder() + .spotId(spotId) + .image(image) + .build()) + .toList(); + + menuboardImageRepository.saveAll(newMenuboardImages); + } + } + + // 9. spotImageList 업데이트 + if (request.spotImageList() != null) { + // 기존 장소 이미지 조회 + List existingSpotImages = spotImageRepository.findAllBySpotIdOrderById(spotId); + + // 기존 이미지 URL 목록 + List existingImageUrls = existingSpotImages.stream() + .map(SpotImageEntity::getImage) + .toList(); + + // 삭제된 이미지만 S3에서 삭제 + List deletedImages = existingImageUrls.stream() + .filter(url -> !request.spotImageList().contains(url)) + .toList(); + + for (String deletedImage : deletedImages) { + s3Adapter.deleteFile(deletedImage); + } + + // 기존 DB 레코드 모두 삭제 (순서 재정렬을 위해) + spotImageRepository.deleteAll(existingSpotImages); + + // 새로운 장소 이미지 추가 + if (!request.spotImageList().isEmpty()) { + List finalImageUrls = new ArrayList<>(); + + for (String imageUrl : request.spotImageList()) { + // 기존 이미지인지 확인 (이미 spot 폴더에 있음) + if (existingImageUrls.contains(imageUrl)) { + finalImageUrls.add(imageUrl); + } else { + // 새 이미지인 경우 temp -> spot 폴더로 이동 + String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + String destinationKey = String.format("spots/%d/spot/%s", spotId, fileName); + String newImageUrl = s3Adapter.moveFile(imageUrl, destinationKey); + finalImageUrls.add(newImageUrl); + } + } + + // DB에 저장 + List newSpotImages = finalImageUrls.stream() + .map(image -> SpotImageEntity.builder() + .spotId(spotId) + .image(image) + .build()) + .toList(); + + spotImageRepository.saveAll(newSpotImages); + } + } + } } diff --git a/src/main/java/com/acon/server/spot/domain/entity/OpeningHour.java b/src/main/java/com/acon/server/spot/domain/entity/OpeningHour.java index 3efea13..37ab9a3 100644 --- a/src/main/java/com/acon/server/spot/domain/entity/OpeningHour.java +++ b/src/main/java/com/acon/server/spot/domain/entity/OpeningHour.java @@ -11,11 +11,12 @@ public class OpeningHour { private final Long id; private final Long spotId; private final DayOfWeek dayOfWeek; - private final Boolean closed; - private final LocalTime startTime; - private final LocalTime endTime; - private final LocalTime breakStartTime; - private final LocalTime breakEndTime; + + private Boolean closed; + private LocalTime startTime; + private LocalTime endTime; + private LocalTime breakStartTime; + private LocalTime breakEndTime; @Builder public OpeningHour( @@ -37,4 +38,24 @@ public OpeningHour( this.breakStartTime = breakStartTime; this.breakEndTime = breakEndTime; } + + public void updateClosed(Boolean closed) { + this.closed = closed; + } + + public void updateStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public void updateEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + public void updateBreakStartTime(LocalTime breakStartTime) { + this.breakStartTime = breakStartTime; + } + + public void updateBreakEndTime(LocalTime breakEndTime) { + this.breakEndTime = breakEndTime; + } } diff --git a/src/main/java/com/acon/server/spot/domain/entity/Spot.java b/src/main/java/com/acon/server/spot/domain/entity/Spot.java index 6e20240..aec8c7e 100644 --- a/src/main/java/com/acon/server/spot/domain/entity/Spot.java +++ b/src/main/java/com/acon/server/spot/domain/entity/Spot.java @@ -15,12 +15,12 @@ public class Spot { private static final GeometryFactory geometryFactory = new GeometryFactory(); private final Long id; - private final String name; - private final SpotType spotType; - private final String address; private final Long appliedUserId; private final Boolean appliedByMember; + private String name; + private SpotType spotType; + private String address; private Integer localAcornCount; private Integer basicAcornCount; private Double latitude; @@ -99,4 +99,24 @@ public void updateLegalDong(String legalDong) { this.legalDong = legalDong; } } + + public void updateName(String name) { + this.name = name; + } + + public void updateAddress(String address) { + this.address = address; + } + + public void updateSpotType(SpotType spotType) { + this.spotType = spotType; + } + + public void updateLocalAcornCount(Integer localAcornCount) { + this.localAcornCount = localAcornCount; + } + + public void updateBasicAcornCount(Integer basicAcornCount) { + this.basicAcornCount = basicAcornCount; + } } diff --git a/src/main/java/com/acon/server/spot/infra/entity/SpotEntity.java b/src/main/java/com/acon/server/spot/infra/entity/SpotEntity.java index 3f044a1..58db2e8 100644 --- a/src/main/java/com/acon/server/spot/infra/entity/SpotEntity.java +++ b/src/main/java/com/acon/server/spot/infra/entity/SpotEntity.java @@ -15,7 +15,6 @@ import jakarta.persistence.Id; import jakarta.persistence.SqlResultSetMapping; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -44,13 +43,7 @@ @Getter @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table( - name = "spot", - uniqueConstraints = @UniqueConstraint( - name = "unique_spot_name_address", - columnNames = {"name", "address"} - ) -) +@Table(name = "spot") // TODO: 공간 인덱스 설정 public class SpotEntity { diff --git a/src/main/java/com/acon/server/spot/infra/repository/MenuRepository.java b/src/main/java/com/acon/server/spot/infra/repository/MenuRepository.java index 354581c..f0bde8d 100644 --- a/src/main/java/com/acon/server/spot/infra/repository/MenuRepository.java +++ b/src/main/java/com/acon/server/spot/infra/repository/MenuRepository.java @@ -6,5 +6,7 @@ public interface MenuRepository extends JpaRepository { + void deleteAllBySpotId(Long spotId); + List findAllBySpotId(Long spotId); } diff --git a/src/main/java/com/acon/server/spot/infra/repository/SpotOptionRepository.java b/src/main/java/com/acon/server/spot/infra/repository/SpotOptionRepository.java index 213a9e7..c547715 100644 --- a/src/main/java/com/acon/server/spot/infra/repository/SpotOptionRepository.java +++ b/src/main/java/com/acon/server/spot/infra/repository/SpotOptionRepository.java @@ -3,11 +3,14 @@ import com.acon.server.spot.infra.entity.SpotOptionEntity; import java.util.List; 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; public interface SpotOptionRepository extends JpaRepository { + void deleteAllBySpotId(Long spotId); + List findAllBySpotId(Long spotId); @Query(value = """ @@ -38,4 +41,16 @@ AND c.name IN ('RESTAURANT_FEATURE', 'CAFE_FEATURE') LIMIT 1 """, nativeQuery = true) String findPriceFeatureBySpotId(@Param("spotId") Long spotId); + + @Modifying + @Query(value = """ + DELETE FROM spot_option + WHERE spot_id = :spotId + AND option_id IN ( + SELECT o.id + FROM "option" o + WHERE o.category_id = :categoryId + ) + """, nativeQuery = true) + void deleteBySpotIdAndCategoryId(@Param("spotId") Long spotId, @Param("categoryId") Long categoryId); } diff --git a/src/main/java/com/acon/server/spot/infra/repository/SpotRepository.java b/src/main/java/com/acon/server/spot/infra/repository/SpotRepository.java index 2b88c1f..b50e758 100644 --- a/src/main/java/com/acon/server/spot/infra/repository/SpotRepository.java +++ b/src/main/java/com/acon/server/spot/infra/repository/SpotRepository.java @@ -18,6 +18,8 @@ public interface SpotRepository extends JpaRepository { boolean existsByNameAndAddressAndSpotStatus(String name, String address, SpotStatus spotStatus); + boolean existsByNameAndAddressAndSpotStatusAndIdNot(String name, String address, SpotStatus spotStatus, Long id); + List findTop10ByNameStartingWithIgnoreCase(String keyword); List findAllByLatitudeIsNullOrLongitudeIsNullOrGeomIsNullOrLegalDongIsNull();