Skip to content

Commit

Permalink
feat: 영수증 이미지 업로드 기능 추가 (#714)
Browse files Browse the repository at this point in the history
* feat: gitignore 추가

* feat: 이미지 업로드/다운로드 기능 추가

* feat: 이미지 업로드/다운로드 기능 추가

* feat: 운영, 개발 image 설정 분리

* feat: 서브모듈 삭제

* test: imageUploadService 추가

* test: 행사 이미지 저장 및 조회 기능 테스트

* feat: 이미지 삭제 기능 구현

* test: 이미지 삭제 테스트 추가

---------

Co-authored-by: juha <[email protected]>
  • Loading branch information
Arachneee and khabh authored Oct 8, 2024
1 parent 6507709 commit 88687f2
Show file tree
Hide file tree
Showing 40 changed files with 526 additions and 46 deletions.
2 changes: 1 addition & 1 deletion server/.gitignore → .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ $RECYCLE.BIN/
*.lnk

### Gradle ###
.gradle
server/.gradle
**/build/
!src/**/build/

Expand Down
2 changes: 2 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
47 changes: 47 additions & 0 deletions server/src/docs/asciidoc/event.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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": "존재하지 않는 행사입니다."
}
]
----
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
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;
import server.haengdong.application.request.EventLoginAppRequest;
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;
Expand All @@ -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) {
Expand Down Expand Up @@ -92,4 +100,41 @@ public void updateEvent(String token, EventUpdateAppRequest request) {
event.changeAccount(request.bankName(), request.accountNumber());
}
}

@Transactional
public void saveImages(String token, List<String> imageNames) {
Event event = getEvent(token);

List<EventImage> images = imageNames.stream()
.map(imageName -> new EventImage(event, imageName))
.toList();

eventImageRepository.saveAll(images);
}

public List<EventImageAppResponse> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> uploadImages(List<MultipartFile> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package server.haengdong.application.response;

public record EventImageAppResponse(
Long id,
String url
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public record MemberAppResponse(
Long id,
String name
) {

public static MemberAppResponse of(Member member) {
return new MemberAppResponse(member.getId(), member.getName());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public record MemberSaveAppResponse(
Long id,
String name
) {

public static MemberSaveAppResponse of(Member member) {
return new MemberSaveAppResponse(member.getId(), member.getName());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
public record MembersSaveAppResponse(
List<MemberSaveAppResponse> members
) {

public static MembersSaveAppResponse of(List<Member> members) {
return new MembersSaveAppResponse(
members.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public record StepAppResponse(
List<BillAppResponse> bills,
List<MemberAppResponse> members
) {

public static StepAppResponse of(Step step) {
List<BillAppResponse> billAppResponses = step.getBills().stream()
.map(BillAppResponse::of)
Expand Down
17 changes: 17 additions & 0 deletions server/src/main/java/server/haengdong/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<EventImage, Long> {

List<EventImage> findAllByEvent(Event event);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("행사에 중복된 참여자 이름이 존재합니다."),
Expand All @@ -29,6 +30,8 @@ public enum HaengdongErrorCode {

DIFFERENT_STEP_MEMBERS("참여자 목록이 일치하지 않습니다."),

IMAGE_NOT_FOUND("존재하지 않는 이미지 입니다."),

/* Authentication */

PASSWORD_INVALID("비밀번호가 일치하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -84,4 +86,11 @@ private ResponseCookie createResponseCookie(String token) {
.maxAge(cookieProperties.maxAge())
.build();
}

@GetMapping("/api/events/{eventId}/images")
public ResponseEntity<EventImagesResponse> findAllImages(@PathVariable("eventId") String token) {
List<EventImageAppResponse> images = eventService.findImages(token);

return ResponseEntity.ok(EventImagesResponse.of(images));
}
}
Loading

0 comments on commit 88687f2

Please sign in to comment.