diff --git a/server/.gitignore b/.gitignore similarity index 99% rename from server/.gitignore rename to .gitignore index 616fb815a..358a2b7b2 100644 --- a/server/.gitignore +++ b/.gitignore @@ -216,7 +216,7 @@ $RECYCLE.BIN/ *.lnk ### Gradle ### -.gradle +server/.gradle **/build/ !src/**/build/ diff --git a/server/build.gradle b/server/build.gradle index c2936590a..e28f5d7de 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -41,6 +41,8 @@ dependencies { runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'software.amazon.awssdk:s3:2.25.27' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/server/src/docs/asciidoc/event.adoc b/server/src/docs/asciidoc/event.adoc index 10b165282..cd37d770a 100644 --- a/server/src/docs/asciidoc/event.adoc +++ b/server/src/docs/asciidoc/event.adoc @@ -203,3 +203,50 @@ operation::authenticateEvent[snippets="http-request,http-response,request-cookie } ] ---- + +=== 행사 이미지 업로드 +operation::uploadImages[snippets="http-request,http-response"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "EVENT_NOT_FOUND", + "message": "존재하지 않는 행사입니다." + }, + { + "code": "IMAGE_UPLOAD_FAIL", + "message": "이미지 업로드에 실패했습니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"만료된 토큰입니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- + +=== 행사 이미지 목록 조회 + +operation::createEvent[snippets="http-request,request-body,request-fields,response-body,response-fields,http-response,response-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "EVENT_NOT_FOUND", + "message": "존재하지 않는 행사입니다." + } +] +---- diff --git a/server/src/main/java/server/haengdong/application/EventService.java b/server/src/main/java/server/haengdong/application/EventService.java index af49ebb5a..2eb80f265 100644 --- a/server/src/main/java/server/haengdong/application/EventService.java +++ b/server/src/main/java/server/haengdong/application/EventService.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map.Entry; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import server.haengdong.application.request.EventAppRequest; @@ -10,14 +11,17 @@ import server.haengdong.application.request.EventUpdateAppRequest; import server.haengdong.application.response.EventAppResponse; import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.EventImageAppResponse; import server.haengdong.application.response.MemberBillReportAppResponse; import server.haengdong.domain.bill.Bill; import server.haengdong.domain.bill.BillRepository; -import server.haengdong.domain.member.Member; import server.haengdong.domain.bill.MemberBillReport; import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventImage; +import server.haengdong.domain.event.EventImageRepository; import server.haengdong.domain.event.EventRepository; import server.haengdong.domain.event.EventTokenProvider; +import server.haengdong.domain.member.Member; import server.haengdong.exception.AuthenticationException; import server.haengdong.exception.HaengdongErrorCode; import server.haengdong.exception.HaengdongException; @@ -30,6 +34,10 @@ public class EventService { private final EventRepository eventRepository; private final EventTokenProvider eventTokenProvider; private final BillRepository billRepository; + private final EventImageRepository eventImageRepository; + + @Value("${image.base-url}") + private String baseUrl; @Transactional public EventAppResponse saveEvent(EventAppRequest request) { @@ -92,4 +100,41 @@ public void updateEvent(String token, EventUpdateAppRequest request) { event.changeAccount(request.bankName(), request.accountNumber()); } } + + @Transactional + public void saveImages(String token, List imageNames) { + Event event = getEvent(token); + + List images = imageNames.stream() + .map(imageName -> new EventImage(event, imageName)) + .toList(); + + eventImageRepository.saveAll(images); + } + + public List findImages(String token) { + Event event = getEvent(token); + + return eventImageRepository.findAllByEvent(event) + .stream() + .map(image -> new EventImageAppResponse(image.getId(), createUrl(image))) + .toList(); + } + + private String createUrl(EventImage image) { + return baseUrl + image.getName(); + } + + @Transactional + public String deleteImage(String token, Long imageId) { + EventImage eventImage = eventImageRepository.findById(imageId) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.IMAGE_NOT_FOUND)); + + Event event = eventImage.getEvent(); + if (event.isTokenMismatch(token)) { + throw new AuthenticationException(HaengdongErrorCode.PASSWORD_INVALID); + } + eventImageRepository.delete(eventImage); + return eventImage.getName(); + } } diff --git a/server/src/main/java/server/haengdong/application/ImageService.java b/server/src/main/java/server/haengdong/application/ImageService.java new file mode 100644 index 000000000..6e0ffdb76 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/ImageService.java @@ -0,0 +1,72 @@ +package server.haengdong.application; + +import static software.amazon.awssdk.core.sync.RequestBody.fromInputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import server.haengdong.application.response.ImageNameAppResponse; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Slf4j +@RequiredArgsConstructor +@Service +public class ImageService { + + @Value("${image.bucket}") + private String bucketName; + + @Value("${image.directory}") + private String directoryPath; + + private final S3Client s3Client; + + public List uploadImages(List images) { + return images.stream() + .map(this::uploadImage) + .toList(); + } + + private String uploadImage(MultipartFile image) { + try (InputStream inputStream = image.getInputStream()) { + return uploadImageToStorage(inputStream, image); + } catch (IOException e) { + throw new HaengdongException(HaengdongErrorCode.IMAGE_UPLOAD_FAIL); + } + } + + private String uploadImageToStorage(InputStream inputStream, MultipartFile image) { + String imageName = UUID.randomUUID() + image.getOriginalFilename(); + String key = directoryPath + imageName; + long contentLength = image.getSize(); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentLength(contentLength) + .contentType(image.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, fromInputStream(inputStream, contentLength)); + return imageName; + } + + public void deleteImage(String imageName) { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(directoryPath + imageName) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/BillAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java index 8a5a5b2bc..6ee68935c 100644 --- a/server/src/main/java/server/haengdong/application/response/BillAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java @@ -8,6 +8,7 @@ public record BillAppResponse( Long price, boolean isFixed ) { + public static BillAppResponse of(Bill bill) { return new BillAppResponse(bill.getId(), bill.getTitle(), bill.getPrice(), bill.isFixed()); } diff --git a/server/src/main/java/server/haengdong/application/response/EventImageAppResponse.java b/server/src/main/java/server/haengdong/application/response/EventImageAppResponse.java new file mode 100644 index 000000000..26e442403 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/EventImageAppResponse.java @@ -0,0 +1,7 @@ +package server.haengdong.application.response; + +public record EventImageAppResponse( + Long id, + String url +) { +} diff --git a/server/src/main/java/server/haengdong/application/response/ImageNameAppResponse.java b/server/src/main/java/server/haengdong/application/response/ImageNameAppResponse.java new file mode 100644 index 000000000..2e6efaceb --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/ImageNameAppResponse.java @@ -0,0 +1,11 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventImage; + +public record ImageNameAppResponse(String name) { + + public EventImage toEventImage(Event event) { + return new EventImage(event, name); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java index b253ea697..98579b2d1 100644 --- a/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java @@ -6,6 +6,7 @@ public record MemberAppResponse( Long id, String name ) { + public static MemberAppResponse of(Member member) { return new MemberAppResponse(member.getId(), member.getName()); } diff --git a/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java index 94dd77117..bb09e02df 100644 --- a/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java @@ -7,6 +7,7 @@ public record MemberDepositAppResponse( String name, boolean isDeposited ) { + public static MemberDepositAppResponse of(Member member) { return new MemberDepositAppResponse(member.getId(), member.getName(), member.isDeposited()); } diff --git a/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java index 1d08536ba..bd1d117f2 100644 --- a/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java @@ -6,6 +6,7 @@ public record MemberSaveAppResponse( Long id, String name ) { + public static MemberSaveAppResponse of(Member member) { return new MemberSaveAppResponse(member.getId(), member.getName()); } diff --git a/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java index e171f9950..c81fe0996 100644 --- a/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java @@ -6,6 +6,7 @@ public record MembersSaveAppResponse( List members ) { + public static MembersSaveAppResponse of(List members) { return new MembersSaveAppResponse( members.stream() diff --git a/server/src/main/java/server/haengdong/application/response/StepAppResponse.java b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java index 2537d1db1..57ca1860c 100644 --- a/server/src/main/java/server/haengdong/application/response/StepAppResponse.java +++ b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java @@ -7,6 +7,7 @@ public record StepAppResponse( List bills, List members ) { + public static StepAppResponse of(Step step) { List billAppResponses = step.getBills().stream() .map(BillAppResponse::of) diff --git a/server/src/main/java/server/haengdong/config/S3Config.java b/server/src/main/java/server/haengdong/config/S3Config.java new file mode 100644 index 000000000..13aa51546 --- /dev/null +++ b/server/src/main/java/server/haengdong/config/S3Config.java @@ -0,0 +1,17 @@ +package server.haengdong.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/server/src/main/java/server/haengdong/domain/event/EventImage.java b/server/src/main/java/server/haengdong/domain/event/EventImage.java index 11419abc8..16b2f5964 100644 --- a/server/src/main/java/server/haengdong/domain/event/EventImage.java +++ b/server/src/main/java/server/haengdong/domain/event/EventImage.java @@ -27,5 +27,10 @@ public class EventImage extends BaseEntity { private Event event; @Column(nullable = false) - private String url; + private String name; + + public EventImage(Event event, String name) { + this.event = event; + this.name = name; + } } diff --git a/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java b/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java new file mode 100644 index 000000000..00f84b8af --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java @@ -0,0 +1,11 @@ +package server.haengdong.domain.event; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventImageRepository extends JpaRepository { + + List findAllByEvent(Event event); +} diff --git a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java index a59288f4b..fc79123b9 100644 --- a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java +++ b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java @@ -13,6 +13,7 @@ public enum HaengdongErrorCode { EVENT_PASSWORD_FORMAT_INVALID("비밀번호는 %d자리 숫자만 가능합니다."), BANK_NAME_INVALID("지원하지 않는 은행입니다. 지원하는 은행 목록: %s"), ACCOUNT_LENGTH_INVALID("계좌번호는 %d자 이상 %d자 이하만 입력 가능합니다."), + IMAGE_UPLOAD_FAIL("이미지 업로드에 실패했습니다."), MEMBER_NAME_LENGTH_INVALID("참여자 이름은 %d자 이상 %d자 이하만 입력 가능합니다."), MEMBER_NAME_DUPLICATE("행사에 중복된 참여자 이름이 존재합니다."), @@ -29,6 +30,8 @@ public enum HaengdongErrorCode { DIFFERENT_STEP_MEMBERS("참여자 목록이 일치하지 않습니다."), + IMAGE_NOT_FOUND("존재하지 않는 이미지 입니다."), + /* Authentication */ PASSWORD_INVALID("비밀번호가 일치하지 않습니다."), diff --git a/server/src/main/java/server/haengdong/presentation/EventController.java b/server/src/main/java/server/haengdong/presentation/EventController.java index 77caa744a..47bdd4fce 100644 --- a/server/src/main/java/server/haengdong/presentation/EventController.java +++ b/server/src/main/java/server/haengdong/presentation/EventController.java @@ -14,12 +14,14 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import server.haengdong.application.AuthService; +import server.haengdong.application.response.EventImageAppResponse; import server.haengdong.application.EventService; import server.haengdong.application.response.MemberBillReportAppResponse; import server.haengdong.infrastructure.auth.CookieProperties; import server.haengdong.presentation.request.EventLoginRequest; import server.haengdong.presentation.request.EventSaveRequest; import server.haengdong.presentation.response.EventDetailResponse; +import server.haengdong.presentation.response.EventImagesResponse; import server.haengdong.presentation.response.EventResponse; import server.haengdong.presentation.response.MemberBillReportsResponse; @@ -84,4 +86,11 @@ private ResponseCookie createResponseCookie(String token) { .maxAge(cookieProperties.maxAge()) .build(); } + + @GetMapping("/api/events/{eventId}/images") + public ResponseEntity findAllImages(@PathVariable("eventId") String token) { + List images = eventService.findImages(token); + + return ResponseEntity.ok(EventImagesResponse.of(images)); + } } diff --git a/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java index 46d11cdd5..8fd913dae 100644 --- a/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java +++ b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java @@ -1,15 +1,21 @@ package server.haengdong.presentation.admin; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; 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.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import server.haengdong.application.EventService; +import server.haengdong.application.ImageService; +import server.haengdong.application.response.ImageNameAppResponse; import server.haengdong.presentation.request.EventUpdateRequest; @Slf4j @@ -18,6 +24,7 @@ public class AdminEventController { private final EventService eventService; + private final ImageService imageUploadService; @PostMapping("/api/admin/events/{eventId}/auth") public ResponseEntity authenticate() { @@ -33,4 +40,26 @@ public ResponseEntity updateEvent( return ResponseEntity.ok().build(); } + + @PostMapping("/api/admin/events/{eventId}/images") + public ResponseEntity uploadImages( + @PathVariable("eventId") String token, + @RequestPart("images") List images + ) { + List imageNames = imageUploadService.uploadImages(images); + eventService.saveImages(token, imageNames); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/admin/events/{eventId}/images/{imageId}") + public ResponseEntity deleteImage( + @PathVariable("eventId") String token, + @PathVariable("imageId") Long imageId + ) { + String imageName = eventService.deleteImage(token, imageId); + imageUploadService.deleteImage(imageName); + + return ResponseEntity.ok().build(); + } } diff --git a/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java index de670f28f..d0f69b148 100644 --- a/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java +++ b/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java @@ -13,6 +13,7 @@ public record BillDetailUpdateRequest( boolean isFixed ) { + public BillDetailUpdateAppRequest toAppRequest() { return new BillDetailUpdateAppRequest(this.id, this.price, this.isFixed); } diff --git a/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java index f09d101bd..091db5967 100644 --- a/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java +++ b/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java @@ -6,8 +6,12 @@ import server.haengdong.application.request.BillDetailsUpdateAppRequest; public record BillDetailsUpdateRequest( - @Valid @NotEmpty List billDetails + + @Valid + @NotEmpty + List billDetails ) { + public BillDetailsUpdateAppRequest toAppRequest() { return new BillDetailsUpdateAppRequest(billDetails.stream() .map(BillDetailUpdateRequest::toAppRequest) diff --git a/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java index a37c75013..d5ea67474 100644 --- a/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java +++ b/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java @@ -12,6 +12,7 @@ public record BillUpdateRequest( @NotNull(message = "지출 금액은 공백일 수 없습니다.") Long price ) { + public BillUpdateAppRequest toAppResponse() { return new BillUpdateAppRequest(title, price); } diff --git a/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java b/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java index a1286e903..d49a0e510 100644 --- a/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java +++ b/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java @@ -8,6 +8,7 @@ public record EventLoginRequest( @NotBlank(message = "비밀번호는 공백일 수 없습니다.") String password ) { + public EventLoginAppRequest toAppRequest(String token) { return new EventLoginAppRequest(token, password); } diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java deleted file mode 100644 index fb1ff655f..000000000 --- a/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package server.haengdong.presentation.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import server.haengdong.application.request.MemberNameUpdateAppRequest; - -public record MemberNameUpdateRequest( - - @NotNull(message = "멤버 id는 공백일 수 없습니다.") - Long id, - - @NotBlank(message = "멤버 이름은 공백일 수 없습니다.") - String name -) { - - public MemberNameUpdateAppRequest toAppRequest() { - return new MemberNameUpdateAppRequest(id, name); - } -} diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java deleted file mode 100644 index 79c46590d..000000000 --- a/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package server.haengdong.presentation.request; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import java.util.List; -import server.haengdong.application.request.MemberNamesUpdateAppRequest; - -public record MemberNamesUpdateRequest( - @Valid - @NotEmpty - List members -) { - - public MemberNamesUpdateAppRequest toAppRequest() { - return new MemberNamesUpdateAppRequest(members.stream() - .map(MemberNameUpdateRequest::toAppRequest) - .toList() - ); - } -} diff --git a/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java index c5e2100fd..cc611742a 100644 --- a/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java +++ b/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java @@ -4,7 +4,10 @@ import server.haengdong.application.request.MemberSaveAppRequest; import server.haengdong.application.request.MembersSaveAppRequest; -public record MembersSaveRequest(List members) { +public record MembersSaveRequest( + List members +) { + public MembersSaveAppRequest toAppRequest() { return new MembersSaveAppRequest(members.stream() .map(member -> new MemberSaveAppRequest(member.name())) diff --git a/server/src/main/java/server/haengdong/presentation/response/BillResponse.java b/server/src/main/java/server/haengdong/presentation/response/BillResponse.java index 8b79896bb..0f3aaeaa0 100644 --- a/server/src/main/java/server/haengdong/presentation/response/BillResponse.java +++ b/server/src/main/java/server/haengdong/presentation/response/BillResponse.java @@ -8,6 +8,7 @@ public record BillResponse( Long price, boolean isFixed ) { + public static BillResponse of(BillAppResponse response) { return new BillResponse(response.id(), response.title(), response.price(), response.isFixed()); } diff --git a/server/src/main/java/server/haengdong/presentation/response/EventImageResponse.java b/server/src/main/java/server/haengdong/presentation/response/EventImageResponse.java new file mode 100644 index 000000000..da807655f --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/EventImageResponse.java @@ -0,0 +1,13 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.EventImageAppResponse; + +public record EventImageResponse( + Long id, + String url +) { + + public static EventImageResponse of(EventImageAppResponse response) { + return new EventImageResponse(response.id(), response.url()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/EventImagesResponse.java b/server/src/main/java/server/haengdong/presentation/response/EventImagesResponse.java new file mode 100644 index 000000000..d5c2b0ab8 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/EventImagesResponse.java @@ -0,0 +1,15 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.EventImageAppResponse; + +public record EventImagesResponse(List images) { + + public static EventImagesResponse of(List responses) { + List images = responses.stream() + .map(EventImageResponse::of) + .toList(); + + return new EventImagesResponse(images); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java index b0740c6ca..21a7e7969 100644 --- a/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java +++ b/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java @@ -6,6 +6,7 @@ public record MemberSaveResponse( Long id, String name ) { + public static MemberSaveResponse of(MemberSaveAppResponse response) { return new MemberSaveResponse(response.id(), response.name()); } diff --git a/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java b/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java index 04e9b133e..d4b7ad456 100644 --- a/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java +++ b/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java @@ -6,6 +6,7 @@ public record MembersSaveResponse( List members ) { + public static MembersSaveResponse of(MembersSaveAppResponse response) { return new MembersSaveResponse( response.members().stream() diff --git a/server/src/main/java/server/haengdong/presentation/response/StepResponse.java b/server/src/main/java/server/haengdong/presentation/response/StepResponse.java index 924651c73..a164e71f5 100644 --- a/server/src/main/java/server/haengdong/presentation/response/StepResponse.java +++ b/server/src/main/java/server/haengdong/presentation/response/StepResponse.java @@ -7,6 +7,7 @@ public record StepResponse( List bills, List members ) { + public static StepResponse of(StepAppResponse response) { List bills = response.bills().stream() .map(BillResponse::of) @@ -16,7 +17,6 @@ public static StepResponse of(StepAppResponse response) { .map(MemberResponse::of) .toList(); return new StepResponse(bills, members); - } } diff --git a/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java b/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java index baceeb549..8a66df1c6 100644 --- a/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java +++ b/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java @@ -6,6 +6,7 @@ public record StepsResponse( List steps ) { + public static StepsResponse of(List steps) { return new StepsResponse(steps.stream() .map(StepResponse::of) diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 22c337545..69472a880 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -4,6 +4,10 @@ spring: url: jdbc:h2:mem:database username: sa password: + servlet: + multipart: + max-file-size: 50MB + max-request-size: 300MB h2: console: @@ -37,6 +41,11 @@ cookie: same-site: none max-age: 7D +image: + bucket: techcourse-project-2024 + directory: haeng-dong/s3-upload-test/ + base-url: https://d2unln22cedgp9.cloudfront.net/ + management: endpoints: web: diff --git a/server/src/test/java/server/haengdong/application/EventServiceTest.java b/server/src/test/java/server/haengdong/application/EventServiceTest.java index e40a682dc..bcfc0a3e4 100644 --- a/server/src/test/java/server/haengdong/application/EventServiceTest.java +++ b/server/src/test/java/server/haengdong/application/EventServiceTest.java @@ -10,14 +10,19 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.MockBean; import server.haengdong.application.request.EventAppRequest; import server.haengdong.application.request.EventUpdateAppRequest; import server.haengdong.application.response.EventAppResponse; import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.EventImageAppResponse; +import server.haengdong.application.response.ImageNameAppResponse; import server.haengdong.application.response.MemberBillReportAppResponse; import server.haengdong.domain.bill.Bill; import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.event.EventImage; +import server.haengdong.domain.event.EventImageRepository; import server.haengdong.domain.member.Member; import server.haengdong.domain.member.MemberRepository; import server.haengdong.domain.event.Event; @@ -39,9 +44,15 @@ class EventServiceTest extends ServiceTestSupport { @Autowired private MemberRepository memberRepository; + @Autowired + private EventImageRepository eventImageRepository; + @MockBean private EventTokenProvider eventTokenProvider; + @Value("${image.base-url}") + private String baseUrl; + @DisplayName("행사를 생성한다") @Test void saveEventTest() { @@ -162,4 +173,59 @@ void getMemberBillReports() { tuple("고구마", 20_000L) ); } + + @DisplayName("행사 이미지를 조회한다.") + @Test + void findAllImages() { + Event event = Fixture.EVENT1; + List eventImages = List.of( + new EventImage(event, "image1.jpg"), + new EventImage(event, "image2.jpg") + ); + eventRepository.save(event); + eventImageRepository.saveAll(eventImages); + + List responses = eventService.findImages(event.getToken()); + + assertThat(responses) + .hasSize(2) + .extracting(EventImageAppResponse::url) + .containsExactlyInAnyOrder( + baseUrl + "image1.jpg", + baseUrl + "image2.jpg" + ); + } + + @DisplayName("행사 이미지를 저장한다.") + @Test + void saveImages() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + List imageNames = List.of("image1.jpg", "image2.jpg"); + + eventService.saveImages(event.getToken(), imageNames); + + List savedEventImages = eventImageRepository.findAllByEvent(event); + assertThat(savedEventImages) + .hasSize(2) + .extracting(EventImage::getName) + .containsExactlyInAnyOrder( + "image1.jpg", + "image2.jpg" + ); + } + + @DisplayName("행사 이미지를 삭제한다.") + @Test + void deleteImage() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + EventImage eventImage = new EventImage(event, "image1.jpg"); + eventImageRepository.save(eventImage); + + eventService.deleteImage(event.getToken(), eventImage.getId()); + + assertThat(eventImageRepository.findById(eventImage.getId())) + .isEmpty(); + } } diff --git a/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java index 22c028169..658c57512 100644 --- a/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java +++ b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java @@ -4,15 +4,19 @@ import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; @@ -20,18 +24,21 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.payload.JsonFieldType; import server.haengdong.application.EventService; +import server.haengdong.application.ImageService; import server.haengdong.presentation.admin.AdminEventController; import server.haengdong.presentation.request.EventUpdateRequest; class AdminEventControllerDocsTest extends RestDocsSupport { private final EventService eventService = mock(EventService.class); + private final ImageService imageUploadService = mock(ImageService.class); @Override protected Object initController() { - return new AdminEventController(eventService); + return new AdminEventController(eventService, imageUploadService); } @DisplayName("행사 어드민 권한을 확인한다.") @@ -92,4 +99,59 @@ void updateEventTest() throws Exception { ) ); } + + @DisplayName("행사에 이미지를 업로드한다.") + @Test + void uploadImages() throws Exception { + String token = "TOKEN"; + MockMultipartFile image1 = new MockMultipartFile("images", "image1.jpg", "image/jpeg", "이미지1".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("images", "image2.jpg", "image/jpeg", "이미지2".getBytes()); + + mockMvc.perform(multipart("/api/admin/events/{eventId}/images", token) + .file(image1) + .file(image2) + .cookie(EVENT_COOKIE) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("uploadImages", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ), + requestParts( + partWithName("images").description("행사 이미지") + ) + ) + ); + } + + @DisplayName("행사 이미지를 삭제한다.") + @Test + void deleteImage() throws Exception { + String token = "TOKEN"; + + mockMvc.perform(delete("/api/admin/events/{eventId}/images/{imageId}", token, 1L) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("deleteImage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("imageId").description("이미지 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ) + ) + ); + } } diff --git a/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java index 83144f135..0918721fd 100644 --- a/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java +++ b/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java @@ -34,6 +34,7 @@ import server.haengdong.application.request.EventAppRequest; import server.haengdong.application.response.EventAppResponse; import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.EventImageAppResponse; import server.haengdong.application.response.MemberBillReportAppResponse; import server.haengdong.infrastructure.auth.CookieProperties; import server.haengdong.presentation.EventController; @@ -192,4 +193,38 @@ void loginEvent() throws Exception { ) ); } + + + @DisplayName("행사 이미지를 조회한다.") + @Test + void findAllImages() throws Exception { + String token = "TOKEN"; + List imageNameAppResponses = List.of( + new EventImageAppResponse(1L, "https://host.com/image1.jpg"), + new EventImageAppResponse(2L, "https://host.com/image2.jpg"), + new EventImageAppResponse(3L, "https://host.com/zeze.jpg") + ); + given(eventService.findImages(token)).willReturn(imageNameAppResponses); + + mockMvc.perform(get("/api/events/{eventId}/images", token)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("findImages", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("images").type(JsonFieldType.ARRAY) + .description("행사 이미지 목록"), + fieldWithPath("images[].id").type(JsonFieldType.NUMBER) + .description("이미지 id"), + fieldWithPath("images[].url").type(JsonFieldType.STRING) + .description("이미지 url") + ) + ) + ); + } } diff --git a/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java index 6ba5540d5..738823c40 100644 --- a/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java +++ b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java @@ -11,6 +11,7 @@ import server.haengdong.application.AuthService; import server.haengdong.application.BillService; import server.haengdong.application.EventService; +import server.haengdong.application.ImageService; import server.haengdong.application.MemberService; import server.haengdong.presentation.admin.AdminBillController; import server.haengdong.presentation.admin.AdminEventController; @@ -46,4 +47,7 @@ public abstract class ControllerTestSupport { @MockBean protected BillService billService; + + @MockBean + protected ImageService imageUploadService; } diff --git a/server/src/test/java/server/haengdong/presentation/EventControllerTest.java b/server/src/test/java/server/haengdong/presentation/EventControllerTest.java index 5facbb740..90e7fd618 100644 --- a/server/src/test/java/server/haengdong/presentation/EventControllerTest.java +++ b/server/src/test/java/server/haengdong/presentation/EventControllerTest.java @@ -14,10 +14,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import server.haengdong.application.request.EventAppRequest; import server.haengdong.application.response.EventAppResponse; import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.EventImageAppResponse; import server.haengdong.application.response.MemberBillReportAppResponse; import server.haengdong.presentation.request.EventLoginRequest; import server.haengdong.presentation.request.EventSaveRequest; @@ -98,4 +100,20 @@ void loginEvent() throws Exception { .andExpect(cookie().value("eventToken", "jwtToken")) .andExpect(status().isOk()); } + + @DisplayName("행사 이미지를 조회한다.") + @Test + void findAllImages() throws Exception { + String token = "TOKEN"; + List imageNameAppResponses = List.of( + new EventImageAppResponse(1L, "https://host.com/image1.jpg"), + new EventImageAppResponse(2L, "https://host.com/image2.jpg"), + new EventImageAppResponse(3L, "https://host.com/zeze.jpg") + ); + given(eventService.findImages(token)).willReturn(imageNameAppResponses); + + mockMvc.perform(RestDocumentationRequestBuilders.get("/api/events/{eventId}/images", token)) + .andDo(print()) + .andExpect(status().isOk()); + } } diff --git a/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java b/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java index df7b70e92..e071d70d3 100644 --- a/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java +++ b/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java @@ -1,12 +1,15 @@ package server.haengdong.presentation.admin; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import server.haengdong.presentation.ControllerTestSupport; import server.haengdong.presentation.request.EventUpdateRequest; @@ -27,4 +30,20 @@ void updateEventTest() throws Exception { .andDo(print()) .andExpect(status().isOk()); } + + @DisplayName("행사에 이미지를 업로드한다.") + @Test + void uploadImages() throws Exception { + String token = "TOKEN"; + MockMultipartFile image1 = new MockMultipartFile("images", "image1.jpg", "image/jpeg", "이미지1".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("images", "image2.jpg", "image/jpeg", "이미지2".getBytes()); + + mockMvc.perform(multipart("/api/admin/events/{eventId}/images", token) + .file(image1) + .file(image2) + .cookie(EVENT_COOKIE) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andDo(print()) + .andExpect(status().isOk()); + } }