diff --git a/build.gradle b/build.gradle index 1985f13..1059596 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,14 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // Resilience4j + implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1' + implementation 'io.github.resilience4j:resilience4j-bulkhead:1.7.1' + // 비동기 실행 + implementation 'org.springframework.boot:spring-boot-starter-aop' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.awaitility:awaitility:4.3.0' testImplementation 'com.h2database:h2' diff --git a/src/main/java/com/example/gtable/global/config/AsyncConfig.java b/src/main/java/com/example/gtable/global/config/AsyncConfig.java new file mode 100644 index 0000000..6b01fae --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/AsyncConfig.java @@ -0,0 +1,21 @@ +package com.example.gtable.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + @Bean(name = "s3UploadExecutor") + public Executor s3UploadExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("S3Upload-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/gtable/global/config/AwsS3Config.java b/src/main/java/com/example/gtable/global/config/AwsS3Config.java new file mode 100644 index 0000000..ffb6bd1 --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/AwsS3Config.java @@ -0,0 +1,33 @@ +package com.example.gtable.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +@Configuration +public class AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client)AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + +} diff --git a/src/main/java/com/example/gtable/global/config/SecurityConfig.java b/src/main/java/com/example/gtable/global/config/SecurityConfig.java index 8c817cc..9c0e836 100644 --- a/src/main/java/com/example/gtable/global/config/SecurityConfig.java +++ b/src/main/java/com/example/gtable/global/config/SecurityConfig.java @@ -51,7 +51,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/oauth2/authorization/kakao", // 카카오 로그인 요청 "/login/oauth2/code/**", // 카카오 인증 콜백 - "/api/refresh-token") // refresh token (토큰 갱신) + "/api/refresh-token", // refresh token (토큰 갱신) + "/stores/**", + "/menus/**") .permitAll() .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 ) diff --git a/src/main/java/com/example/gtable/global/s3/S3Service.java b/src/main/java/com/example/gtable/global/s3/S3Service.java new file mode 100644 index 0000000..acf83d0 --- /dev/null +++ b/src/main/java/com/example/gtable/global/s3/S3Service.java @@ -0,0 +1,57 @@ +package com.example.gtable.global.s3; + +import java.io.InputStream; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; + +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public record S3UploadResult(String key, String url) { + } + + @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL) + @Async("s3UploadExecutor") + public CompletableFuture upload(String type, Long refId, MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + String key = createFileKey(type, refId, file.getOriginalFilename()); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + + amazonS3Client.putObject(bucket, key, inputStream, metadata); + String url = amazonS3Client.getUrl(bucket, key).toString(); + + return CompletableFuture.completedFuture(new S3UploadResult(key, url)); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + } + + public void delete(String filename) { + try { + amazonS3Client.deleteObject(bucket, filename); + } catch (Exception e) { + throw new RuntimeException("S3 파일 삭제 실패", e); + } + } + + private String createFileKey(String type, Long refId, String filename) { + return type + "/" + refId + "/" + UUID.randomUUID() + "-" + filename; + } +} diff --git a/src/main/java/com/example/gtable/menu/controller/MenuController.java b/src/main/java/com/example/gtable/menu/controller/MenuController.java new file mode 100644 index 0000000..5631cae --- /dev/null +++ b/src/main/java/com/example/gtable/menu/controller/MenuController.java @@ -0,0 +1,50 @@ +package com.example.gtable.menu.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gtable.global.api.ApiUtils; +import com.example.gtable.menu.dto.MenuCreateRequest; +import com.example.gtable.menu.dto.MenuCreateResponse; +import com.example.gtable.menu.service.MenuService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/menus") +@RequiredArgsConstructor +public class MenuController { + + private final MenuService menuService; + + @PostMapping("/create") + public ResponseEntity createMenu(@Valid @RequestBody MenuCreateRequest request) { + MenuCreateResponse response = menuService.createMenu(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/{storeId}") + public ResponseEntity getMenusByStoreId(@PathVariable Long storeId) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getMenusByStoreId(storeId) + ) + ); + } +} diff --git a/src/main/java/com/example/gtable/menu/dto/MenuCreateRequest.java b/src/main/java/com/example/gtable/menu/dto/MenuCreateRequest.java new file mode 100644 index 0000000..a4f6229 --- /dev/null +++ b/src/main/java/com/example/gtable/menu/dto/MenuCreateRequest.java @@ -0,0 +1,33 @@ +package com.example.gtable.menu.dto; + +import com.example.gtable.menu.model.Menu; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MenuCreateRequest { + + @NotNull + private Long storeId; + @NotNull + private String name; + @NotNull + private String description; + @NotNull + private String price; + + public Menu toEntity() { + return Menu.builder() + .storeId(storeId) + .name(name) + .description(description) + .price(price) + .build(); + } +} + diff --git a/src/main/java/com/example/gtable/menu/dto/MenuCreateResponse.java b/src/main/java/com/example/gtable/menu/dto/MenuCreateResponse.java new file mode 100644 index 0000000..e17f0f4 --- /dev/null +++ b/src/main/java/com/example/gtable/menu/dto/MenuCreateResponse.java @@ -0,0 +1,32 @@ +package com.example.gtable.menu.dto; + +import java.time.LocalDateTime; + +import com.example.gtable.menu.model.Menu; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class MenuCreateResponse { + private Long menuId; + private Long storeId; + private String name; + private String description; + private String price; + private LocalDateTime createdAt; + + public static MenuCreateResponse fromEntity(Menu menu) { + return MenuCreateResponse.builder() + .createdAt(menu.getCreatedAt()) + .menuId(menu.getId()) + .storeId(menu.getStoreId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/menu/dto/MenuReadDto.java b/src/main/java/com/example/gtable/menu/dto/MenuReadDto.java new file mode 100644 index 0000000..347baeb --- /dev/null +++ b/src/main/java/com/example/gtable/menu/dto/MenuReadDto.java @@ -0,0 +1,33 @@ +package com.example.gtable.menu.dto; + +import java.util.List; + +import com.example.gtable.menu.model.Menu; +import com.example.gtable.menuImage.dto.MenuImageUploadResponse; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class MenuReadDto { + private Long menuId; + private Long storeId; + private String name; + private String description; + private String price; + private List images; + + public static MenuReadDto fromEntity(Menu menu, List images) { + return MenuReadDto.builder() + .menuId(menu.getId()) + .storeId(menu.getStoreId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .images(images) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/menu/dto/MenuReadResponse.java b/src/main/java/com/example/gtable/menu/dto/MenuReadResponse.java new file mode 100644 index 0000000..d88562d --- /dev/null +++ b/src/main/java/com/example/gtable/menu/dto/MenuReadResponse.java @@ -0,0 +1,21 @@ +package com.example.gtable.menu.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class MenuReadResponse { + + private List menuReadDto; + + public static MenuReadResponse of(List menuReadDto) { + return MenuReadResponse.builder() + .menuReadDto(menuReadDto) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/menu/model/Menu.java b/src/main/java/com/example/gtable/menu/model/Menu.java new file mode 100644 index 0000000..da46d73 --- /dev/null +++ b/src/main/java/com/example/gtable/menu/model/Menu.java @@ -0,0 +1,42 @@ +package com.example.gtable.menu.model; + +import java.time.LocalDateTime; + +import com.example.gtable.global.entity.BaseTimeEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuperBuilder +public class Menu extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + private Long storeId; + private String name; + private String description; + private String price; + + public Menu(LocalDateTime createdAt, Long id, Long storeId, String name, String description, String price) { + super(createdAt); + this.Id = id; + this.storeId = storeId; + this.name = name; + this.description = description; + this.price = price; + } +} diff --git a/src/main/java/com/example/gtable/menu/repository/MenuRepository.java b/src/main/java/com/example/gtable/menu/repository/MenuRepository.java new file mode 100644 index 0000000..abded50 --- /dev/null +++ b/src/main/java/com/example/gtable/menu/repository/MenuRepository.java @@ -0,0 +1,13 @@ +package com.example.gtable.menu.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.gtable.menu.model.Menu; + +@Repository +public interface MenuRepository extends JpaRepository { + List findAllByStoreId(Long storeId); +} diff --git a/src/main/java/com/example/gtable/menu/service/MenuService.java b/src/main/java/com/example/gtable/menu/service/MenuService.java new file mode 100644 index 0000000..05ad340 --- /dev/null +++ b/src/main/java/com/example/gtable/menu/service/MenuService.java @@ -0,0 +1,52 @@ +package com.example.gtable.menu.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gtable.menu.dto.MenuCreateRequest; +import com.example.gtable.menu.dto.MenuCreateResponse; +import com.example.gtable.menu.dto.MenuReadDto; +import com.example.gtable.menu.dto.MenuReadResponse; +import com.example.gtable.menu.model.Menu; +import com.example.gtable.menu.repository.MenuRepository; +import com.example.gtable.menuImage.dto.MenuImageUploadResponse; +import com.example.gtable.menuImage.model.MenuImage; +import com.example.gtable.menuImage.repository.MenuImageRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuService { + + private final MenuRepository menuRepository; + private final MenuImageRepository menuImageRepository; + + @Transactional + public MenuCreateResponse createMenu(MenuCreateRequest request) { + Menu toSave = request.toEntity(); + + Menu saved = menuRepository.save(toSave); + + return MenuCreateResponse.fromEntity(saved); + } + + @Transactional(readOnly = true) + public MenuReadResponse getMenusByStoreId(Long storeId) { + List menus = menuRepository.findAllByStoreId(storeId); + + List menuReadRespons = menus.stream() + .map(menu -> { + List images = menuImageRepository.findByMenu(menu); + List imageDto = images.stream() + .map(MenuImageUploadResponse::fromEntity) + .toList(); + return MenuReadDto.fromEntity(menu, imageDto); + }) + .toList(); + + return MenuReadResponse.of(menuReadRespons); + } +} diff --git a/src/main/java/com/example/gtable/menuImage/controller/MenuImageController.java b/src/main/java/com/example/gtable/menuImage/controller/MenuImageController.java new file mode 100644 index 0000000..1f716a2 --- /dev/null +++ b/src/main/java/com/example/gtable/menuImage/controller/MenuImageController.java @@ -0,0 +1,57 @@ +package com.example.gtable.menuImage.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.api.ApiUtils; +import com.example.gtable.menuImage.dto.MenuImageUploadResponse; +import com.example.gtable.menuImage.service.MenuImageService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/menus") +@RequiredArgsConstructor +public class MenuImageController { + + private final MenuImageService menuImageService; + + @PostMapping("/{menuId}/images") + public ResponseEntity uploadMenuImage( + @PathVariable Long menuId, + @RequestParam("file") MultipartFile file + ) { + if (file.isEmpty()) { + throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다."); + } + if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한 + throw new IllegalArgumentException("파일 크기는 10MB를 초과할 수 없습니다."); + } + + MenuImageUploadResponse response = menuImageService.save(menuId, file); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiUtils.success(response)); + } + + @DeleteMapping("/image/{id}") + public ResponseEntity deleteMenuImage(@PathVariable Long id) { + menuImageService.delete(id); + return ResponseEntity + .status( + HttpStatus.NO_CONTENT + ) + .body( + ApiUtils + .success( + "Menu image deleted successfully." + ) + ); + } + +} diff --git a/src/main/java/com/example/gtable/menuImage/dto/MenuImageUploadResponse.java b/src/main/java/com/example/gtable/menuImage/dto/MenuImageUploadResponse.java new file mode 100644 index 0000000..47e64a4 --- /dev/null +++ b/src/main/java/com/example/gtable/menuImage/dto/MenuImageUploadResponse.java @@ -0,0 +1,20 @@ +package com.example.gtable.menuImage.dto; + +import com.example.gtable.menuImage.model.MenuImage; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MenuImageUploadResponse { + private final Long id; + private final String imageUrl; + + public static MenuImageUploadResponse fromEntity(MenuImage menuImage) { + return MenuImageUploadResponse.builder() + .id(menuImage.getId()) + .imageUrl(menuImage.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/menuImage/model/MenuImage.java b/src/main/java/com/example/gtable/menuImage/model/MenuImage.java new file mode 100644 index 0000000..489b912 --- /dev/null +++ b/src/main/java/com/example/gtable/menuImage/model/MenuImage.java @@ -0,0 +1,40 @@ +package com.example.gtable.menuImage.model; + +import com.example.gtable.global.entity.BaseTimeEntity; +import com.example.gtable.menu.model.Menu; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "menu_images") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +public class MenuImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "menu_id") + private Menu menu; + + @Column(nullable = false, length = 500) + private String imageUrl; + + @Column(nullable = false, length = 500) + private String fileKey; +} diff --git a/src/main/java/com/example/gtable/menuImage/repository/MenuImageRepository.java b/src/main/java/com/example/gtable/menuImage/repository/MenuImageRepository.java new file mode 100644 index 0000000..84a36d3 --- /dev/null +++ b/src/main/java/com/example/gtable/menuImage/repository/MenuImageRepository.java @@ -0,0 +1,14 @@ +package com.example.gtable.menuImage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.gtable.menu.model.Menu; +import com.example.gtable.menuImage.model.MenuImage; + +@Repository +public interface MenuImageRepository extends JpaRepository { + List findByMenu(Menu menu); +} diff --git a/src/main/java/com/example/gtable/menuImage/service/MenuImageService.java b/src/main/java/com/example/gtable/menuImage/service/MenuImageService.java new file mode 100644 index 0000000..471b2ad --- /dev/null +++ b/src/main/java/com/example/gtable/menuImage/service/MenuImageService.java @@ -0,0 +1,54 @@ +package com.example.gtable.menuImage.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.s3.S3Service; +import com.example.gtable.menu.model.Menu; +import com.example.gtable.menu.repository.MenuRepository; +import com.example.gtable.menuImage.dto.MenuImageUploadResponse; +import com.example.gtable.menuImage.model.MenuImage; +import com.example.gtable.menuImage.repository.MenuImageRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuImageService { + + private final MenuRepository menuRepository; + private final MenuImageRepository menuImageRepository; + private final S3Service s3Service; + + @Transactional + public MenuImageUploadResponse save(Long menuId, MultipartFile file) { + + String type = "menu"; + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new IllegalArgumentException("Menu not found with id: " + menuId)); + + S3Service.S3UploadResult uploadResult = s3Service.upload(type, menuId, file).join(); + + // MenuImage 엔티티 생성 및 저장 + MenuImage menuImage = MenuImage.builder() + .menu(menu) + .imageUrl(uploadResult.url()) + .fileKey(uploadResult.key()) + .build(); + + menuImageRepository.save(menuImage); + + // 응답 생성 + return MenuImageUploadResponse.fromEntity(menuImage); + } + + @Transactional + public void delete(Long id) { + MenuImage menuImage = menuImageRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("MenuImage not found with id: " + id)); + + s3Service.delete(menuImage.getFileKey()); + menuImageRepository.delete(menuImage); + } +} diff --git a/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java b/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java index e400dd3..cdcda45 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java +++ b/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java @@ -23,15 +23,12 @@ public class StoreCreateRequest { private String description; - private String storeImageUrl; - public Store toEntity() { return Store.builder() .departmentId(departmentId) .name(name) .location(location) .description(description) - .storeImageUrl(storeImageUrl) .isActive(false) .deleted(false) .build(); diff --git a/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java b/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java index caeeaf4..0e5bfb4 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java +++ b/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java @@ -18,7 +18,6 @@ public class StoreCreateResponse { private String name; private String location; private String description; - private String storeImageUrl; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; @@ -31,7 +30,6 @@ public static StoreCreateResponse fromEntity(Store store) { .name(store.getName()) .location(store.getLocation()) .description(store.getDescription()) - .storeImageUrl(store.getStoreImageUrl()) .isActive(store.getIsActive()) .deleted(store.getDeleted()) .build(); diff --git a/src/main/java/com/example/gtable/store/dto/StoreReadDto.java b/src/main/java/com/example/gtable/store/dto/StoreReadDto.java index 610f176..1fb4579 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreReadDto.java +++ b/src/main/java/com/example/gtable/store/dto/StoreReadDto.java @@ -1,8 +1,10 @@ package com.example.gtable.store.dto; import java.time.LocalDateTime; +import java.util.List; import com.example.gtable.store.model.Store; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,12 +19,12 @@ public class StoreReadDto { private String name; private String location; private String description; - private String storeImageUrl; + private List images; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; - public static StoreReadDto fromEntity(Store store) { + public static StoreReadDto fromEntity(Store store, List images) { return StoreReadDto.builder() .createdAt(store.getCreatedAt()) .storeId(store.getStoreId()) @@ -30,9 +32,9 @@ public static StoreReadDto fromEntity(Store store) { .name(store.getName()) .location(store.getLocation()) .description(store.getDescription()) - .storeImageUrl(store.getStoreImageUrl()) .isActive(store.getIsActive()) .deleted(store.getDeleted()) + .images(images) .build(); } } diff --git a/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java b/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java index 061206f..b44c9f7 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java +++ b/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java @@ -14,7 +14,7 @@ public class StoreReadResponse { private List storeReadDtos; private boolean hasNext; - public static StoreReadResponse fromEntity(List storeReadDtos, boolean hasNext) { + public static StoreReadResponse of(List storeReadDtos, boolean hasNext) { return StoreReadResponse.builder() .storeReadDtos(storeReadDtos) .hasNext(hasNext) diff --git a/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java b/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java index 033b8ee..cb95159 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java +++ b/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java @@ -13,6 +13,5 @@ public class StoreUpdateRequest { private String name; private String location; private String description; - private String storeImageUrl; private Boolean isActive; } diff --git a/src/main/java/com/example/gtable/store/model/Store.java b/src/main/java/com/example/gtable/store/model/Store.java index cefa782..32dc069 100644 --- a/src/main/java/com/example/gtable/store/model/Store.java +++ b/src/main/java/com/example/gtable/store/model/Store.java @@ -36,8 +36,6 @@ public class Store extends BaseTimeEntity { private String description; - private String storeImageUrl; - @Column(name = "is_active", nullable = false) private Boolean isActive = false; @@ -45,39 +43,32 @@ public class Store extends BaseTimeEntity { private Boolean deleted = false; public Store(LocalDateTime createdAt, Long storeId, Long departmentId, String name, String location, - String description, String storeImageUrl, Boolean isActive, Boolean deleted) { + String description, Boolean isActive, Boolean deleted) { super(createdAt); this.storeId = storeId; this.departmentId = departmentId; this.name = name; this.location = location; this.description = description; - this.storeImageUrl = storeImageUrl; this.isActive = isActive; this.deleted = deleted; } - public void setName(String name) { + public void updateInfo(String name, String location, String description) { this.name = name; - } - - public void setLocation(String location) { this.location = location; - } - - public void setDescription(String description) { this.description = description; } - public void setStoreImageUrl(String url) { - this.storeImageUrl = url; + public void markAsDeleted() { + this.deleted = true; } - public void setIsActive(Boolean isActive) { - this.isActive = isActive; + public void activate() { + this.isActive = true; } - public void setDeleted(Boolean deleted) { - this.deleted = deleted; + public void deactivate() { + this.isActive = false; } } diff --git a/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java b/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java index 165f3e6..17518cf 100644 --- a/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java +++ b/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java @@ -12,6 +12,9 @@ import com.example.gtable.store.dto.StoreUpdateRequest; import com.example.gtable.store.model.Store; import com.example.gtable.store.repository.StoreRepository; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.model.StoreImage; +import com.example.gtable.storeImage.repository.StoreImageRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; @@ -21,6 +24,7 @@ public class StoreServiceImpl implements StoreService { private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; @Override @Transactional @@ -38,15 +42,18 @@ public StoreReadResponse getAllStores() { List stores = storeRepository.findAllByDeletedFalse(); List storeRead = stores.stream() - .map(StoreReadDto::fromEntity) + .map(store -> { + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + return StoreReadDto.fromEntity(store, imageDto); + }) .toList(); boolean hasNext = false; - return StoreReadResponse.fromEntity( - storeRead, - hasNext - ); + return StoreReadResponse.of(storeRead, hasNext); } @Override @@ -55,7 +62,12 @@ public StoreReadDto getStoreByStoreId(Long storeId) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - return StoreReadDto.fromEntity(store); + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(store, imageDto); } @Override @@ -64,20 +76,20 @@ public StoreReadDto updateStore(Long storeId, StoreUpdateRequest request) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - if (request.getName() != null) - store.setName(request.getName()); - if (request.getLocation() != null) - store.setLocation(request.getLocation()); - if (request.getDescription() != null) - store.setDescription(request.getDescription()); - if (request.getStoreImageUrl() != null) - store.setStoreImageUrl(request.getStoreImageUrl()); - if (request.getIsActive() != null) - store.setIsActive(request.getIsActive()); + store.updateInfo( + request.getName(), + request.getLocation(), + request.getDescription() + ); Store updatedStore = storeRepository.save(store); - return StoreReadDto.fromEntity(updatedStore); + List images = storeImageRepository.findByStore(updatedStore); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(updatedStore, imageDto); } @Override @@ -86,7 +98,7 @@ public String deleteStore(Long storeId) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - store.setDeleted(true); + store.markAsDeleted(); storeRepository.save(store); return "Store ID " + storeId + " 삭제되었습니다."; diff --git a/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java b/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java new file mode 100644 index 0000000..998c7bc --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java @@ -0,0 +1,71 @@ +package com.example.gtable.storeImage.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.api.ApiUtils; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.service.StoreImageService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/stores") +@RequiredArgsConstructor +public class StoreImageController { + + private final StoreImageService storeImageService; + + @PostMapping("/{storeId}/images") + public ResponseEntity uploadStoreImage( + @PathVariable Long storeId, + @RequestParam("files") List files, + @RequestParam(value = "types") List types + ) { + // TODO 관련 정책 확정되면 메서드로 분리 예정 + // 파일 개수 제한 검증 + if (files.isEmpty() || files.size() > 10) { + throw new IllegalArgumentException("파일은 1개 이상 10개 이하로 업로드해 주세요."); + } + // 파일 크기 검증 + for (MultipartFile file : files) { + if (file.isEmpty()) { + throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다."); + } + if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한 + throw new IllegalArgumentException("파일 크기는 10MB를 초과할 수 없습니다."); + } + } + + List response = storeImageService.saveAll(storeId, files, types); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @DeleteMapping("/image/{imageId}") + public ResponseEntity deleteStoreImage(@PathVariable Long imageId) { + storeImageService.delete(imageId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .body( + ApiUtils + .success( + "Store image deleted successfully." + ) + ); + } +} diff --git a/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java b/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java new file mode 100644 index 0000000..0fdfc3e --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java @@ -0,0 +1,22 @@ +package com.example.gtable.storeImage.dto; + +import com.example.gtable.storeImage.model.StoreImage; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StoreImageUploadResponse { + private final Long id; + private final String imageUrl; + private final String type; + + public static StoreImageUploadResponse fromEntity(StoreImage storeImage) { + return StoreImageUploadResponse.builder() + .id(storeImage.getId()) + .imageUrl(storeImage.getImageUrl()) + .type(storeImage.getType()) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/storeImage/model/StoreImage.java b/src/main/java/com/example/gtable/storeImage/model/StoreImage.java new file mode 100644 index 0000000..3224888 --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/model/StoreImage.java @@ -0,0 +1,43 @@ +package com.example.gtable.storeImage.model; + +import com.example.gtable.global.entity.BaseTimeEntity; +import com.example.gtable.store.model.Store; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "store_images") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +public class StoreImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @Column(nullable = false, length = 500) + private String imageUrl; + + @Column(nullable = false, length = 500) + private String fileKey; + + @Column(length = 20) + private String type; +} diff --git a/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java b/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java new file mode 100644 index 0000000..e7db0a0 --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java @@ -0,0 +1,15 @@ +package com.example.gtable.storeImage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.gtable.store.model.Store; +import com.example.gtable.storeImage.model.StoreImage; + +@Repository +public interface StoreImageRepository extends JpaRepository { + + List findByStore(Store store); +} diff --git a/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java b/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java new file mode 100644 index 0000000..b26b5e5 --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java @@ -0,0 +1,81 @@ +package com.example.gtable.storeImage.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.s3.S3Service; +import com.example.gtable.store.model.Store; +import com.example.gtable.store.repository.StoreRepository; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.model.StoreImage; +import com.example.gtable.storeImage.repository.StoreImageRepository; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreImageService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + private final S3Service s3Service; + + @Transactional + public List saveAll(Long storeId, List files, List types) { + if (files.size() != types.size()) { + throw new IllegalArgumentException("파일과 타입의 개수가 일치해야 합니다."); + } + + String type = "store"; + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new EntityNotFoundException("Store not found with id: " + storeId)); + + // 모든 파일을 비동기로 업로드 + List> uploadFutures = new ArrayList<>(); + for (MultipartFile file : files) { + uploadFutures.add(s3Service.upload(type, storeId, file)); + } + + // 모든 업로드 완료 대기 + List uploadResults; + try { + uploadResults = uploadFutures.stream() + .map(CompletableFuture::join) + .toList(); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + + // DB 저장은 모든 S3 업로드 성공 후 수행 + List imageUploadResponses = new ArrayList<>(); + for (int i = 0; i < uploadResults.size(); i++) { + S3Service.S3UploadResult uploadResult = uploadResults.get(i); + StoreImage storeImage = StoreImage.builder() + .store(store) + .imageUrl(uploadResult.url()) + .fileKey(uploadResult.key()) + .type(types.get(i)) + .build(); + + storeImageRepository.save(storeImage); + imageUploadResponses.add(StoreImageUploadResponse.fromEntity(storeImage)); + } + + return imageUploadResponses; + } + + @Transactional + public void delete(Long storeImageId) { + StoreImage storeImage = storeImageRepository.findById(storeImageId) + .orElseThrow(() -> new EntityNotFoundException("StoreImage not found with id: " + storeImageId)); + + s3Service.delete(storeImage.getFileKey()); + storeImageRepository.delete(storeImage); + } +}