diff --git a/build.gradle b/build.gradle index 3e59600..3a450f4 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,10 @@ dependencies { // AWS S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Image + implementation("com.sksamuel.scrimage:scrimage-core:4.3.5") + implementation("com.sksamuel.scrimage:scrimage-webp:4.3.5") } tasks.named('test') { diff --git a/src/main/java/com/sku/refit/domain/event/controller/EventController.java b/src/main/java/com/sku/refit/domain/event/controller/EventController.java new file mode 100644 index 0000000..5505622 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/controller/EventController.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.event.dto.request.EventRequest.*; +import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "행사", description = "행사 관련 API") +@RequestMapping("/api/events") +public interface EventController { + + /* ========================= + * Admin + * ========================= */ + + @PostMapping(value = "/admin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "[관리자] 행사 생성", description = "행사를 생성합니다.") + ResponseEntity> createEvent( + @Parameter( + description = "행사 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart("request") + @Valid + EventInfoRequest request, + @Parameter( + description = "대표 사진(썸네일)", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart("thumbnail") + MultipartFile thumbnail); + + @PutMapping(value = "/admin/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "[관리자] 행사 수정", description = "행사를 수정합니다.") + ResponseEntity> updateEvent( + @PathVariable Long id, + @RequestPart("request") @Valid EventInfoRequest request, + @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail); + + @DeleteMapping("/admin/{id}") + @Operation(summary = "[관리자] 행사 삭제", description = "행사 뿐만 아니라 대표사진 및 이미지들까지 모두 삭제합니다.") + ResponseEntity> deleteEvent(@PathVariable Long id); + + /* ========================= + * Public List + * ========================= */ + + @GetMapping("/upcoming") + @Operation(summary = "예정된 행사 리스트", description = "예정된 행사만 조회합니다. D-day가 가까운 순(오름차순)으로 정렬합니다.") + ResponseEntity>> getUpcomingEvents(); + + @GetMapping("/end") + @Operation(summary = "종료된 행사 리스트", description = "종료된 행사만 조회합니다. 최근 종료 순(내림차순)으로 정렬합니다.") + ResponseEntity>> getEndedEvents(); + + @GetMapping + @Operation( + summary = "다가오는/예정된/종료된 행사 조회", + description = "다가오는(가장 가까운 D-day 1개) / 예정된(그 다음 1개) / 종료된(가장 최근 종료 1개)로 반환합니다.") + ResponseEntity> getEventGroups(); + + /* ========================= + * Detail + * ========================= */ + + @GetMapping("/{id}") + @Operation( + summary = "행사 상세 조회", + description = "행사 예약 여부 + 행사 정보 + 최근 예약 이미지 4장 + 4장 제외 의류수를 반환합니다.") + ResponseEntity> getEventDetail(@PathVariable Long id); + + @GetMapping("/{id}/img") + @Operation(summary = "행사 더보기 이미지 조회", description = "해당 행사의 예약에서 업로드된 모든 옷 사진을 최신 등록순으로 반환합니다.") + ResponseEntity>> getEventAllReservationImages( + @PathVariable Long id); + + /* ========================= + * Reservation + * ========================= */ + + @PostMapping(value = "/{id}/rsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "행사 예약", description = "예약 정보를 저장하고 업로드 이미지는 WebP로 저장합니다.") + ResponseEntity> reserveEvent( + @PathVariable Long id, + @RequestPart("request") @Valid EventRsvRequest request, + @RequestPart(value = "clothImageList", required = false) List clothImageList); +} diff --git a/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java b/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java new file mode 100644 index 0000000..49a4bf9 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.event.dto.request.EventRequest.EventInfoRequest; +import com.sku.refit.domain.event.dto.request.EventRequest.EventRsvRequest; +import com.sku.refit.domain.event.dto.response.EventResponse.EventCardResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventDetailResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventGroupResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventImageResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventReservationResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventSimpleResponse; +import com.sku.refit.domain.event.service.EventService; +import com.sku.refit.global.response.BaseResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class EventControllerImpl implements EventController { + + private final EventService eventService; + + @Override + public ResponseEntity> createEvent( + @RequestPart("request") @Valid EventInfoRequest request, + @RequestPart("thumbnail") MultipartFile thumbnail) { + + return ResponseEntity.ok(BaseResponse.success(eventService.createEvent(request, thumbnail))); + } + + @Override + public ResponseEntity> updateEvent( + @PathVariable Long id, + @RequestPart("request") @Valid EventInfoRequest request, + @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail) { + + return ResponseEntity.ok( + BaseResponse.success(eventService.updateEvent(id, request, thumbnail))); + } + + @Override + public ResponseEntity> deleteEvent(@PathVariable Long id) { + eventService.deleteEvent(id); + return ResponseEntity.ok(BaseResponse.success(null)); + } + + @Override + public ResponseEntity>> getUpcomingEvents() { + return ResponseEntity.ok(BaseResponse.success(eventService.getUpcomingEvents())); + } + + @Override + public ResponseEntity>> getEndedEvents() { + return ResponseEntity.ok(BaseResponse.success(eventService.getEndedEvents())); + } + + @Override + public ResponseEntity> getEventGroups() { + return ResponseEntity.ok(BaseResponse.success(eventService.getEventGroups())); + } + + @Override + public ResponseEntity> getEventDetail(@PathVariable Long id) { + return ResponseEntity.ok(BaseResponse.success(eventService.getEventDetail(id))); + } + + @Override + public ResponseEntity>> getEventAllReservationImages( + @PathVariable Long id) { + + return ResponseEntity.ok(BaseResponse.success(eventService.getEventAllReservationImages(id))); + } + + @Override + public ResponseEntity> reserveEvent( + @PathVariable Long id, + @RequestPart("request") @Valid EventRsvRequest request, + @RequestPart(value = "clothImageList", required = false) List clothImageList) { + + return ResponseEntity.ok( + BaseResponse.success(eventService.reserveEvent(id, request, clothImageList))); + } +} diff --git a/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java b/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java new file mode 100644 index 0000000..2a188b3 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.dto.request; + +import java.time.LocalDate; + +import jakarta.validation.constraints.*; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +public class EventRequest { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(title = "EventInfoRequest DTO", description = "행사 생성 및 수정 요청 데이터") + public static class EventInfoRequest { + + @NotBlank(message = "행사명은 필수입니다.") + @Schema(description = "행사명", example = "2025 21% 파티") + private String name; + + @NotBlank(message = "행사 설명은 필수입니다.") + @Schema(description = "행사 설명", example = "파티에 대한 짧은 설명입니다.") + private String description; + + @NotNull(message = "행사 날짜는 필수입니다.") @Schema(description = "행사 날짜", example = "2025-12-24") + private LocalDate date; + + @NotBlank(message = "행사 장소는 필수입니다.") + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + + @Schema(description = "행사 상세 링크", example = "https://wearagain.org/48") + private String detailLink; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(title = "EventRsvRequest DTO", description = "행사 예약 요청 데이터") + public static class EventRsvRequest { + + @NotBlank(message = "예약자 이름은 필수입니다.") + @Schema(description = "예약자 이름", example = "김재생") + private String name; + + @NotBlank(message = "연락처는 필수입니다.") + @Schema(description = "연락처", example = "010-1234-5678") + private String phone; + + @Email(message = "이메일 형식이 올바르지 않습니다.") + @NotBlank(message = "이메일은 필수입니다.") + @Schema(description = "이메일", example = "test@example.com") + private String email; + + @Min(value = 0, message = "옷 수량은 0 이상이어야 합니다.") + @Schema(description = "가져올 옷 수량", example = "3") + private Integer clothCount; + + @NotNull(message = "수신 동의 여부는 필수입니다.") @Schema(description = "마케팅/알림 수신 동의 여부", example = "true") + private Boolean marketingConsent; + } +} diff --git a/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java b/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java new file mode 100644 index 0000000..d11fba6 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(title = "EventResponse DTO", description = "행사 관련 응답 데이터") +public class EventResponse { + + @Getter + @Builder + @Schema(title = "EventDetailResponse DTO", description = "행사 상세 조회 응답") + public static class EventDetailResponse { + + @Schema(description = "현재 사용자가 해당 행사를 이미 예약했는지 여부 (비로그인 시 null)", example = "true") + private Boolean isReserved; + + /* ===== 상단 ===== */ + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "누적 예약 인원(옷 수량 기준)", example = "25") + private Integer totalReservedCount; + + @Schema(description = "행사 대표 이미지 URL") + private String thumbnailUrl; + + @Schema(description = "행사명", example = "겨울 의류 나눔 행사") + private String name; + + @Schema(description = "행사 설명", example = "따뜻한 겨울을 위한 의류 기부 행사입니다.") + private String description; + + @Schema(description = "행사 상세 링크", example = "https://example.com/events/1") + private String detailLink; + + /* ===== 중간 ===== */ + + @Schema(description = "행사 날짜", example = "2025-12-24") + private LocalDate date; + + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + + /* ===== 하단 ===== */ + + @Schema(description = "최근 등록된 예약 이미지 4장 URL 리스트") + private List recentImageUrlList; + + @Schema(description = "최근 4장을 제외한 의류 수량", example = "21") + private Integer clothCountExceptRecent4; + } + + @Getter + @Builder + @Schema(title = "EventImageResponse DTO", description = "행사 예약 이미지 응답") + public static class EventImageResponse { + + @Schema(description = "이미지 정렬 순서(ID 기준 최신순)", example = "15") + private Long order; + + @Schema(description = "이미지 URL") + private String imageUrl; + } + + @Getter + @Builder + @Schema(title = "EventReservationResponse DTO", description = "행사 예약 결과 응답") + public static class EventReservationResponse { + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "예약 완료 여부", example = "true") + private Boolean reserved; + + @Schema(description = "예약 완료 후 누적 예약 인원", example = "28") + private Integer totalReservedCount; + } + + /* ===== 리스트용 ===== */ + + @Getter + @Builder + @Schema(title = "EventCardResponse DTO", description = "다가오는 행사 카드형 응답") + public static class EventCardResponse { + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "행사 대표 이미지 URL") + private String thumbnailUrl; + + @Schema(description = "D-day (행사까지 남은 일 수)", example = "5") + private Long dday; + + @Schema(description = "행사명", example = "겨울 의류 나눔 행사") + private String name; + + @Schema(description = "행사 설명", example = "따뜻한 겨울을 위한 의류 기부 행사입니다.") + private String description; + + @Schema(description = "행사 날짜", example = "2025-12-24") + private LocalDate date; + + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + } + + @Getter + @Builder + @Schema(title = "EventSimpleResponse DTO", description = "예정/종료 행사 간단 응답") + public static class EventSimpleResponse { + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "행사 대표 이미지 URL") + private String thumbnailUrl; + + @Schema(description = "행사명", example = "겨울 의류 나눔 행사") + private String name; + + @Schema(description = "행사 날짜", example = "2025-12-24") + private LocalDate date; + + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + } + + @Getter + @Builder + @Schema(title = "EventGroupResponse DTO", description = "행사 분류별 리스트 응답") + public static class EventGroupResponse { + + @Schema(description = "다가오는 행사") + private EventCardResponse upcoming; + + @Schema(description = "예정된 행사") + private EventSimpleResponse scheduled; + + @Schema(description = "종료된 행사") + private EventSimpleResponse ended; + } +} diff --git a/src/main/java/com/sku/refit/domain/event/entity/Event.java b/src/main/java/com/sku/refit/domain/event/entity/Event.java new file mode 100644 index 0000000..c343597 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/entity/Event.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.entity; + +import java.time.LocalDate; + +import jakarta.persistence.*; + +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "event") +public class Event extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private LocalDate date; + + @Column(nullable = false) + private String location; + + @Column private String detailLink; + + @Column(nullable = false) + private String thumbnailUrl; + + @Column(nullable = false) + @Builder.Default + private Integer totalReservedCount = 0; + + public void update( + String name, String description, LocalDate date, String location, String detailLink) { + this.name = name; + this.description = description; + this.date = date; + this.location = location; + this.detailLink = detailLink; + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public void increaseReservedCount() { + this.totalReservedCount = (this.totalReservedCount == null ? 0 : this.totalReservedCount) + 1; + } +} diff --git a/src/main/java/com/sku/refit/domain/event/entity/EventReservation.java b/src/main/java/com/sku/refit/domain/event/entity/EventReservation.java new file mode 100644 index 0000000..6898072 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/entity/EventReservation.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.entity; + +import jakarta.persistence.*; + +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + name = "event_reservation", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_event_user", + columnNames = {"event_id", "user_id"}) + }) +public class EventReservation extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String phone; + + @Column(nullable = false) + private String email; + + @Column(nullable = false) + @Builder.Default + private Integer clothCount = 0; + + @Column(nullable = false) + private Boolean marketingConsent; +} diff --git a/src/main/java/com/sku/refit/domain/event/entity/EventReservationImage.java b/src/main/java/com/sku/refit/domain/event/entity/EventReservationImage.java new file mode 100644 index 0000000..14b484d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/entity/EventReservationImage.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.entity; + +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 com.sku.refit.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "event_reservation_image") +public class EventReservationImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + private EventReservation reservation; + + @Column(nullable = false) + private String imageUrl; +} diff --git a/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java b/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java new file mode 100644 index 0000000..c1d63af --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum EventErrorCode implements BaseErrorCode { + EVENT_NOT_FOUND("EVENT001", "행사가 존재하지 않습니다.", HttpStatus.NOT_FOUND), + + EVENT_THUMBNAIL_REQUIRED("EVENT010", "행사 대표 사진은 필수입니다.", HttpStatus.BAD_REQUEST), + EVENT_THUMBNAIL_UPLOAD_FAILED( + "EVENT011", "행사 대표 사진 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_THUMBNAIL_DELETE_FAILED( + "EVENT012", "행사 대표 사진 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + EVENT_CREATE_FAILED("EVENT013", "행사 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_UPDATE_FAILED("EVENT014", "행사 수정에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + EVENT_DELETE_FAILED("EVENT015", "행사 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_RESERVATION_IMAGES_DELETE_FAILED( + "EVENT016", "예약 이미지 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + EVENT_ALREADY_RESERVED("EVENT020", "이미 예약한 행사입니다.", HttpStatus.CONFLICT), + EVENT_RESERVATION_CREATE_FAILED( + "EVENT021", "행사 예약 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_RESERVATION_IMAGE_UPLOAD_FAILED( + "EVENT022", "예약 이미지 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java b/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java new file mode 100644 index 0000000..3d86911 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.mapper; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.event.dto.request.EventRequest.*; +import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.domain.event.entity.Event; +import com.sku.refit.domain.event.entity.EventReservation; +import com.sku.refit.domain.event.entity.EventReservationImage; +import com.sku.refit.domain.user.entity.User; + +@Component +public class EventMapper { + + public Event toEvent(EventInfoRequest req, String thumbnailUrl) { + return Event.builder() + .name(req.getName()) + .description(req.getDescription()) + .date(req.getDate()) + .location(req.getLocation()) + .detailLink(req.getDetailLink()) + .thumbnailUrl(thumbnailUrl) + .totalReservedCount(0) + .build(); + } + + public EventDetailResponse toDetail( + Event event, Boolean isReserved, List recent4, int clothCountExcept4) { + return EventDetailResponse.builder() + .isReserved(isReserved) + .eventId(event.getId()) + .totalReservedCount(event.getTotalReservedCount()) + .thumbnailUrl(event.getThumbnailUrl()) + .name(event.getName()) + .description(event.getDescription()) + .detailLink(event.getDetailLink()) + .date(event.getDate()) + .location(event.getLocation()) + .recentImageUrlList(recent4) + .clothCountExceptRecent4(clothCountExcept4) + .build(); + } + + public EventImageResponse toImageResponse(EventReservationImage img) { + return EventImageResponse.builder().order(img.getId()).imageUrl(img.getImageUrl()).build(); + } + + /* ========================= + * List Responses + * ========================= */ + + public EventCardResponse toUpcomingCard(Event event, LocalDate today) { + long dday = ChronoUnit.DAYS.between(today, event.getDate()); + return EventCardResponse.builder() + .eventId(event.getId()) + .thumbnailUrl(event.getThumbnailUrl()) + .dday(dday) + .name(event.getName()) + .description(event.getDescription()) + .date(event.getDate()) + .location(event.getLocation()) + .build(); + } + + public EventSimpleResponse toSimple(Event event) { + return EventSimpleResponse.builder() + .eventId(event.getId()) + .thumbnailUrl(event.getThumbnailUrl()) + .name(event.getName()) + .date(event.getDate()) + .location(event.getLocation()) + .build(); + } + + public List toUpcomingCardList(List events, LocalDate today) { + return events.stream().map(e -> toUpcomingCard(e, today)).toList(); + } + + public List toSimpleList(List events) { + return events.stream().map(this::toSimple).toList(); + } + + public EventGroupResponse toGroupResponseSingle( + EventCardResponse upcoming, EventSimpleResponse scheduled, EventSimpleResponse ended) { + return EventGroupResponse.builder() + .upcoming(upcoming) + .scheduled(scheduled) + .ended(ended) + .build(); + } + + /* ========================= + * Reservation Response + * ========================= */ + public EventReservation toReservation(Event event, User user, EventRsvRequest request) { + return EventReservation.builder() + .event(event) + .user(user) + .name(request.getName()) + .phone(request.getPhone()) + .email(request.getEmail()) + .clothCount(request.getClothCount() == null ? 0 : request.getClothCount()) + .marketingConsent(request.getMarketingConsent()) + .build(); + } + + public EventReservationResponse toReservationResponse(Event event) { + return EventReservationResponse.builder() + .eventId(event.getId()) + .reserved(true) + .totalReservedCount(event.getTotalReservedCount()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java b/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java new file mode 100644 index 0000000..75649b1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sku.refit.domain.event.entity.Event; + +public interface EventRepository extends JpaRepository { + List findByDateGreaterThanEqualOrderByDateAsc(LocalDate today); + + List findByDateLessThanOrderByDateDesc(LocalDate today); + + List findByDateGreaterThanEqualOrderByDateAsc(LocalDate date, Pageable pageable); + + List findByDateLessThanOrderByDateDesc(LocalDate date, Pageable pageable); +} diff --git a/src/main/java/com/sku/refit/domain/event/repository/EventReservationImageRepository.java b/src/main/java/com/sku/refit/domain/event/repository/EventReservationImageRepository.java new file mode 100644 index 0000000..23d73a2 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/repository/EventReservationImageRepository.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sku.refit.domain.event.entity.EventReservationImage; + +public interface EventReservationImageRepository + extends JpaRepository { + + List findTop4ByReservation_Event_IdOrderByIdDesc(Long eventId); + + int countByReservation_Event_Id(Long eventId); + + List findAllByReservation_Event_IdOrderByIdDesc(Long eventId); +} diff --git a/src/main/java/com/sku/refit/domain/event/repository/EventReservationRepository.java b/src/main/java/com/sku/refit/domain/event/repository/EventReservationRepository.java new file mode 100644 index 0000000..325902c --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/repository/EventReservationRepository.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sku.refit.domain.event.entity.EventReservation; + +public interface EventReservationRepository extends JpaRepository { + + boolean existsByEventIdAndUserId(Long eventId, Long userId); + + List findByEventId(Long eventId); +} diff --git a/src/main/java/com/sku/refit/domain/event/service/EventService.java b/src/main/java/com/sku/refit/domain/event/service/EventService.java new file mode 100644 index 0000000..8b1acf6 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/service/EventService.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.service; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.event.dto.request.EventRequest.*; +import com.sku.refit.domain.event.dto.response.EventResponse.*; + +/** + * 행사(Event) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. + * + *

주요 기능: + * + *

    + *
  • 행사 생성 / 수정 / 삭제 (관리자) + *
  • 행사 목록 조회 (예정 / 종료 / 그룹) + *
  • 행사 상세 조회 + *
  • 행사 예약 및 예약 이미지 업로드 + *
  • 행사 예약 이미지 전체 조회 + *
+ */ +public interface EventService { + + /* ========================= + * Admin + * ========================= */ + + /** + * 새로운 행사를 생성합니다. (관리자 전용) + * + *

대표 사진은 WebP 포맷으로 변환되어 S3에 저장됩니다. + * + * @param request 행사 기본 정보 (행사명, 설명, 날짜, 장소, 상세 링크) + * @param thumbnail 행사 대표 이미지 + * @return 생성된 행사 상세 응답 + */ + EventDetailResponse createEvent(EventInfoRequest request, MultipartFile thumbnail); + + /** + * 기존 행사를 수정합니다. (관리자 전용) + * + *

대표 이미지를 함께 전달할 경우 기존 이미지는 삭제되고 새 이미지로 교체됩니다. + * + * @param id 수정할 행사 ID + * @param request 수정할 행사 정보 + * @param thumbnail 새 대표 이미지 (선택) + * @return 수정된 행사 상세 응답 + */ + EventDetailResponse updateEvent(Long id, EventInfoRequest request, MultipartFile thumbnail); + + /** + * 행사를 삭제합니다. (관리자 전용) + * + *

대표 이미지 및 해당 행사에 등록된 모든 예약 이미지가 함께 삭제됩니다. + * + * @param id 삭제할 행사 ID + */ + void deleteEvent(Long id); + + /* ========================= + * List + * ========================= */ + + /** + * 예정된 행사 목록을 조회합니다. + * + *

행사 날짜 기준 오름차순으로 정렬되며, D-Day 정보가 포함된 카드 형태의 응답을 반환합니다. + * + * @return 예정된 행사 카드 응답 리스트 + */ + List getUpcomingEvents(); + + /** + * 종료된 행사 목록을 조회합니다. + * + *

행사 날짜 기준 내림차순으로 정렬되며, 간단한 행사 정보 형태의 응답을 반환합니다. + * + * @return 종료된 행사 간단 응답 리스트 + */ + List getEndedEvents(); + + /** + * 행사 목록을 그룹 형태로 조회합니다. + * + *

응답은 다음 세 그룹으로 구성됩니다. + * + *

    + *
  • 다가오는 행사 (카드 형태, D-Day 포함) + *
  • 예정된 행사 (간단 정보 형태) + *
  • 종료된 행사 (간단 정보 형태) + *
+ * + * @return 행사 그룹 응답 + */ + EventGroupResponse getEventGroups(); + + /* ========================= + * Detail + * ========================= */ + + /** + * 특정 행사에 대한 상세 정보를 조회합니다. + * + *

로그인 상태인 경우, 사용자의 해당 행사 예약 여부가 함께 반환됩니다. 비로그인 상태인 경우 예약 여부는 {@code null}로 반환됩니다. + * + *

상세 응답에는 다음 정보가 포함됩니다. + * + *

    + *
  • 누적 예약 인원 + *
  • 대표 이미지, 행사명, 설명, 상세 링크 + *
  • 행사 날짜 및 장소 + *
  • 최근 등록된 예약 이미지 4장 + *
  • 최근 4장을 제외한 의류 수량 + *
+ * + * @param id 조회할 행사 ID + * @return 행사 상세 응답 + */ + EventDetailResponse getEventDetail(Long id); + + /** + * 특정 행사에 등록된 모든 예약 이미지를 조회합니다. + * + *

이미지는 최신 등록 순으로 반환되며, 페이징 없이 전체 이미지를 조회합니다. + * + * @param eventId 행사 ID + * @return 행사 예약 이미지 응답 리스트 + */ + List getEventAllReservationImages(Long eventId); + + /* ========================= + * Reservation + * ========================= */ + + /** + * 특정 행사에 대해 예약을 진행합니다. + * + *

예약 시 다음 정보가 함께 저장됩니다. + * + *

    + *
  • 예약자 정보 (이름, 연락처, 이메일) + *
  • 의류 수량 + *
  • 마케팅 수신 동의 여부 + *
  • 행사에 가져올 의류 이미지 (WebP 변환 후 저장) + *
+ * + *

예약이 완료되면 행사 누적 예약 수량이 증가합니다. + * + * @param eventId 예약할 행사 ID + * @param request 예약 요청 정보 + * @param clothImageList 예약 시 업로드하는 의류 이미지 목록 + * @return 행사 예약 결과 응답 + */ + EventReservationResponse reserveEvent( + Long eventId, EventRsvRequest request, List clothImageList); +} diff --git a/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java b/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java new file mode 100644 index 0000000..8d253c5 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.event.dto.request.EventRequest.*; +import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.domain.event.entity.Event; +import com.sku.refit.domain.event.entity.EventReservation; +import com.sku.refit.domain.event.entity.EventReservationImage; +import com.sku.refit.domain.event.exception.EventErrorCode; +import com.sku.refit.domain.event.mapper.EventMapper; +import com.sku.refit.domain.event.repository.EventRepository; +import com.sku.refit.domain.event.repository.EventReservationImageRepository; +import com.sku.refit.domain.event.repository.EventReservationRepository; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; +import com.sku.refit.global.s3.entity.PathName; +import com.sku.refit.global.s3.service.S3Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class EventServiceImpl implements EventService { + + private final EventRepository eventRepository; + private final EventReservationRepository eventReservationRepository; + private final EventReservationImageRepository eventReservationImageRepository; + + private final S3Service s3Service; + private final UserService userService; + private final EventMapper eventMapper; + + /* ========================= + * Admin + * ========================= */ + + @Override + @Transactional + public EventDetailResponse createEvent(EventInfoRequest request, MultipartFile thumbnail) { + + if (thumbnail == null || thumbnail.isEmpty()) { + throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_REQUIRED); + } + + String thumbnailUrl; + try { + thumbnailUrl = s3Service.uploadFileAsWebp(PathName.EVENT, thumbnail); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] createEvent - thumbnail upload failed", e); + throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_UPLOAD_FAILED); + } + + try { + Event event = eventMapper.toEvent(request, thumbnailUrl); + eventRepository.save(event); + return getEventDetail(event.getId()); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] createEvent - failed", e); + throw new CustomException(EventErrorCode.EVENT_CREATE_FAILED); + } + } + + @Override + @Transactional + public EventDetailResponse updateEvent( + Long id, EventInfoRequest request, MultipartFile thumbnail) { + + Event event = + eventRepository + .findById(id) + .orElseThrow(() -> new CustomException(EventErrorCode.EVENT_NOT_FOUND)); + + try { + event.update( + request.getName(), + request.getDescription(), + request.getDate(), + request.getLocation(), + request.getDetailLink()); + + if (thumbnail != null && !thumbnail.isEmpty()) { + replaceThumbnail(event, id, thumbnail); + } + + return getEventDetail(event.getId()); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] updateEvent - failed, eventId={}", id, e); + throw new CustomException(EventErrorCode.EVENT_UPDATE_FAILED); + } + } + + private void replaceThumbnail(Event event, Long eventId, MultipartFile thumbnail) { + String oldThumbUrl = event.getThumbnailUrl(); + + final String newThumbUrl; + try { + newThumbUrl = s3Service.uploadFileAsWebp(PathName.EVENT, thumbnail); + } catch (Exception e) { + log.error("[EVENT] updateEvent - thumbnail upload failed, eventId={}", eventId, e); + throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_UPLOAD_FAILED); + } + + event.updateThumbnailUrl(newThumbUrl); + + if (oldThumbUrl != null) { + try { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(oldThumbUrl)); + } catch (Exception e) { + log.error("[EVENT] updateEvent - thumbnail delete failed, eventId={}", eventId, e); + throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_DELETE_FAILED); + } + } + } + + @Override + @Transactional + public void deleteEvent(Long id) { + + Event event = + eventRepository + .findById(id) + .orElseThrow(() -> new CustomException(EventErrorCode.EVENT_NOT_FOUND)); + + try { + if (event.getThumbnailUrl() != null) { + try { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(event.getThumbnailUrl())); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] deleteEvent - thumbnail delete failed, eventId={}", id, e); + throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_DELETE_FAILED); + } + } + List images = + eventReservationImageRepository.findAllByReservation_Event_IdOrderByIdDesc(id); + + for (EventReservationImage img : images) { + try { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(img.getImageUrl())); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error( + "[EVENT] deleteEvent - reservation image delete failed, eventId={}, imageId={}", + id, + img.getId(), + e); + throw new CustomException(EventErrorCode.EVENT_RESERVATION_IMAGES_DELETE_FAILED); + } + } + + eventReservationImageRepository.deleteAll(images); + + List reservations = eventReservationRepository.findByEventId(id); + eventReservationRepository.deleteAll(reservations); + + eventRepository.delete(event); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] deleteEvent - failed, eventId={}", id, e); + throw new CustomException(EventErrorCode.EVENT_DELETE_FAILED); + } + } + + /* ========================= + * List + * ========================= */ + + @Override + public List getUpcomingEvents() { + + LocalDate today = LocalDate.now(); + List upcoming = eventRepository.findByDateGreaterThanEqualOrderByDateAsc(today); + + return eventMapper.toUpcomingCardList(upcoming, today); + } + + @Override + public List getEndedEvents() { + + LocalDate today = LocalDate.now(); + List ended = eventRepository.findByDateLessThanOrderByDateDesc(today); + + return eventMapper.toSimpleList(ended); + } + + @Override + public EventGroupResponse getEventGroups() { + LocalDate today = LocalDate.now(); + + List top2Upcoming = + eventRepository.findByDateGreaterThanEqualOrderByDateAsc(today, PageRequest.of(0, 2)); + + Event upcomingEvent = top2Upcoming.size() >= 1 ? top2Upcoming.get(0) : null; + Event scheduledEvent = top2Upcoming.size() >= 2 ? top2Upcoming.get(1) : null; + + Event endedEvent = + eventRepository.findByDateLessThanOrderByDateDesc(today, PageRequest.of(0, 1)).stream() + .findFirst() + .orElse(null); + + EventCardResponse upcoming = + (upcomingEvent == null) ? null : eventMapper.toUpcomingCard(upcomingEvent, today); + + EventSimpleResponse scheduled = + (scheduledEvent == null) ? null : eventMapper.toSimple(scheduledEvent); + + EventSimpleResponse ended = (endedEvent == null) ? null : eventMapper.toSimple(endedEvent); + + return eventMapper.toGroupResponseSingle(upcoming, scheduled, ended); + } + + /* ========================= + * Detail + * ========================= */ + + @Override + public EventDetailResponse getEventDetail(Long id) { + + Event event = + eventRepository + .findById(id) + .orElseThrow(() -> new CustomException(EventErrorCode.EVENT_NOT_FOUND)); + + User user = null; + try { + user = userService.getCurrentUser(); + } catch (Exception e) { + log.debug("[EVENT] getEventDetail - unauthenticated request"); + } + + Boolean isReserved = null; + if (user != null) { + isReserved = eventReservationRepository.existsByEventIdAndUserId(id, user.getId()); + } + + List recent4 = + eventReservationImageRepository.findTop4ByReservation_Event_IdOrderByIdDesc(id).stream() + .map(EventReservationImage::getImageUrl) + .toList(); + + int totalUploadedClothCount = eventReservationImageRepository.countByReservation_Event_Id(id); + + int clothCountExcept4 = Math.max(0, totalUploadedClothCount - 4); + + return eventMapper.toDetail(event, isReserved, recent4, clothCountExcept4); + } + + @Override + public List getEventAllReservationImages(Long eventId) { + + eventRepository + .findById(eventId) + .orElseThrow(() -> new CustomException(EventErrorCode.EVENT_NOT_FOUND)); + + return eventReservationImageRepository + .findAllByReservation_Event_IdOrderByIdDesc(eventId) + .stream() + .map(eventMapper::toImageResponse) + .toList(); + } + + /* ========================= + * Reservation + * ========================= */ + + @Override + @Transactional + public EventReservationResponse reserveEvent( + Long eventId, EventRsvRequest request, List clothImageList) { + + Event event = + eventRepository + .findById(eventId) + .orElseThrow(() -> new CustomException(EventErrorCode.EVENT_NOT_FOUND)); + + User user = userService.getCurrentUser(); + + if (eventReservationRepository.existsByEventIdAndUserId(eventId, user.getId())) { + throw new CustomException(EventErrorCode.EVENT_ALREADY_RESERVED); + } + + EventReservation reservation = eventMapper.toReservation(event, user, request); + + eventReservationRepository.save(reservation); + + if (clothImageList != null && !clothImageList.isEmpty()) { + for (MultipartFile f : clothImageList) { + try { + String url = s3Service.uploadFileAsWebp(PathName.EVENT, f); + + eventReservationImageRepository.save( + EventReservationImage.builder().reservation(reservation).imageUrl(url).build()); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] reserveEvent - image upload failed, eventId={}", eventId, e); + throw new CustomException(EventErrorCode.EVENT_RESERVATION_IMAGE_UPLOAD_FAILED); + } + } + } + + event.increaseReservedCount(); + + return eventMapper.toReservationResponse(event); + } +} diff --git a/src/main/java/com/sku/refit/global/config/S3Config.java b/src/main/java/com/sku/refit/global/config/S3Config.java index dd829a9..9ff4c50 100644 --- a/src/main/java/com/sku/refit/global/config/S3Config.java +++ b/src/main/java/com/sku/refit/global/config/S3Config.java @@ -45,6 +45,9 @@ public class S3Config { @Value("${cloud.aws.s3.path.cloth}") private String clothPath; + @Value("${cloud.aws.s3.path.event}") + private String eventPath; + @PostConstruct public void init() { this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); diff --git a/src/main/java/com/sku/refit/global/config/SecurityConfig.java b/src/main/java/com/sku/refit/global/config/SecurityConfig.java index e17aeec..a03c6dd 100644 --- a/src/main/java/com/sku/refit/global/config/SecurityConfig.java +++ b/src/main/java/com/sku/refit/global/config/SecurityConfig.java @@ -125,7 +125,7 @@ private void configureAuthorization(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/error") .permitAll() - .requestMatchers(RegexRequestMatcher.regexMatcher(".*/admin/.*")) + .requestMatchers(RegexRequestMatcher.regexMatcher(".*/admin($|/.*)")) .hasRole("ADMIN") .requestMatchers(RegexRequestMatcher.regexMatcher(".*/dev/.*")) .hasRole("DEVELOPER") diff --git a/src/main/java/com/sku/refit/global/s3/entity/PathName.java b/src/main/java/com/sku/refit/global/s3/entity/PathName.java index 6f75c12..9c40689 100644 --- a/src/main/java/com/sku/refit/global/s3/entity/PathName.java +++ b/src/main/java/com/sku/refit/global/s3/entity/PathName.java @@ -12,4 +12,6 @@ public enum PathName { POST, @Schema(description = "옷") CLOTH, + @Schema(description = "행사") + EVENT } diff --git a/src/main/java/com/sku/refit/global/s3/service/S3Service.java b/src/main/java/com/sku/refit/global/s3/service/S3Service.java index 71258ac..08b02f4 100644 --- a/src/main/java/com/sku/refit/global/s3/service/S3Service.java +++ b/src/main/java/com/sku/refit/global/s3/service/S3Service.java @@ -39,4 +39,6 @@ String uploadFile( String extractKeyNameFromUrl(String imageUrl); void fileExists(String keyName); + + String uploadFileAsWebp(PathName pathName, MultipartFile file); } diff --git a/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java b/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java index d9b8864..d350341 100644 --- a/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java +++ b/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java @@ -3,6 +3,9 @@ */ package com.sku.refit.global.s3.service; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.util.List; import java.util.UUID; @@ -16,6 +19,8 @@ import com.amazonaws.services.s3.model.ListObjectsV2Request; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; import com.sku.refit.global.config.S3Config; import com.sku.refit.global.exception.CustomException; import com.sku.refit.global.s3.dto.S3Response; @@ -30,6 +35,8 @@ @RequiredArgsConstructor public class S3ServiceImpl implements S3Service { + private static final int WEBP_QUALITY = 90; + private final AmazonS3 amazonS3; private final S3Config s3Config; @@ -194,6 +201,80 @@ private String getPrefix(PathName pathName) { case PROFILE_IMAGE -> s3Config.getProfileImagePath(); case POST -> s3Config.getPostPath(); case CLOTH -> s3Config.getClothPath(); + case EVENT -> s3Config.getEventPath(); }; } + + @Override + public String uploadFileAsWebp(PathName pathName, MultipartFile file) { + + validateFile(file); + + byte[] webpBytes = convertToWebp(file); + + String keyName = getPrefix(pathName) + "/" + UUID.randomUUID() + ".webp"; + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(webpBytes.length); + metadata.setContentType("image/webp"); + + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(webpBytes)) { + amazonS3.putObject( + new PutObjectRequest(s3Config.getBucket(), keyName, inputStream, metadata)); + + log.info("파일(WebP) 업로드 성공 - bucket: {}, keyName: {}", s3Config.getBucket(), keyName); + + return amazonS3.getUrl(s3Config.getBucket(), keyName).toString(); + + } catch (Exception e) { + log.error("S3 WebP 업로드 중 오류 발생 - bucket: {}, keyName: {}", s3Config.getBucket(), keyName, e); + throw new CustomException(S3ErrorStatus.FILE_SERVER_ERROR); + } + } + + private byte[] convertToWebp(MultipartFile file) { + + try { + ImmutableImage image; + try { + image = ImmutableImage.loader().fromStream(file.getInputStream()); + } catch (IOException e) { + log.error( + "이미지 디코딩 오류 - originalFilename: {}, message: {}", + file.getOriginalFilename(), + e.getMessage()); + throw new CustomException(S3ErrorStatus.FILE_SERVER_ERROR); + } + + if (image == null) { + log.warn("이미지 디코딩 실패 - originalFilename: {}", file.getOriginalFilename()); + throw new CustomException(S3ErrorStatus.FILE_SERVER_ERROR); + } + + WebpWriter writer = WebpWriter.DEFAULT.withQ(WEBP_QUALITY); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + image.forWriter(writer).write(baos); + } catch (IOException e) { + log.error( + "WebP 변환 중 IO 오류 - originalFilename: {}, message: {}", + file.getOriginalFilename(), + e.getMessage()); + throw new CustomException(S3ErrorStatus.FILE_SERVER_ERROR); + } + + return baos.toByteArray(); + + } catch (CustomException e) { + throw e; + + } catch (Exception e) { + log.error( + "WebP 변환 중 예기치 않은 오류 - originalFilename: {}, message: {}", + file.getOriginalFilename(), + e.getMessage()); + throw new CustomException(S3ErrorStatus.FILE_SERVER_ERROR); + } + } } diff --git a/src/main/resources b/src/main/resources index 5471022..d23b7c2 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit 547102251cecc07b04c80516470cd71d4f4a2145 +Subproject commit d23b7c2b63504e84ea800b139b7e9235d4bf045f