Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a055766
Chore: presentation단의 String타입 Json응답 파싱을 위한 공용 dto 생성
GitJIHO Sep 8, 2025
904e4ff
Feat: User관련 exception global handler에 추가
GitJIHO Sep 8, 2025
4248c23
Feat: DDIP Event 비즈니스 로직 루트 애그리거트에 구현
GitJIHO Sep 8, 2025
72c2640
Feat: 루트 애그리거트 하위 Photo 비즈니스 로직 구현
GitJIHO Sep 8, 2025
f460515
Feat: Action type 추가
GitJIHO Sep 8, 2025
6b13983
Fix: Mapper에서 빈 List 초기화의 경우 가변적으로 생성하도록 변경
GitJIHO Sep 8, 2025
0e507cc
Feat: DDIP Service 비즈니스 로직 구현
GitJIHO Sep 8, 2025
0179b7c
Chore: S3 구현체를 위한 config
GitJIHO Sep 8, 2025
9a89a53
Chore: S3 구현체를 위한 gradle 의존성 추가
GitJIHO Sep 8, 2025
ba66648
Feat: File 저장 로직에서의 공통된 exception 정의
GitJIHO Sep 8, 2025
8bf1429
Feat: File 저장 포트 정의
GitJIHO Sep 8, 2025
8dc4623
Feat: 파일 저장 S3 기반 구현체 구현
GitJIHO Sep 8, 2025
98ef7b4
Feat: DTO 정의
GitJIHO Sep 8, 2025
aff67e2
Feat: DDIP API 포트단 로직 추가
GitJIHO Sep 8, 2025
b560288
Feat: DDIP API 기반 presentation단 controller 구현체 구현
GitJIHO Sep 8, 2025
2dba0ec
Chore: S3용 properties 세팅
GitJIHO Sep 8, 2025
0e8d4a7
Test: 환경변수 추가
GitJIHO Sep 8, 2025
cd72fd3
Test: 변경사항 기반 DDIP Service 테스트 수정
GitJIHO Sep 8, 2025
64013e4
Merge branch 'main' of https://github.com/dev-DDIP/ddip-BE into Feat/…
GitJIHO Sep 8, 2025
05d046a
Test: S3 file 구현체 테스트
GitJIHO Sep 8, 2025
372f5d0
Test: DDIP Service 테스트 추가
GitJIHO Sep 8, 2025
18373a5
Test: DDIP Event 테스트 추가
GitJIHO Sep 8, 2025
fe5ec6d
Test: Photo 도메인 비즈니스로직 테스트 추가
GitJIHO Sep 8, 2025
a7cccaf
Test: Mapper 테스트 추가
GitJIHO Sep 8, 2025
b798cf3
Refactor: 코드 자동 리팩터링
GitJIHO Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ dependencies {
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.23.1'

// AWS S3
implementation 'software.amazon.awssdk:s3:2.21.29'
implementation 'software.amazon.awssdk:auth:2.21.29'
implementation 'software.amazon.awssdk:regions:2.21.29'

// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/com/knu/ddip/common/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.knu.ddip.common.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class S3Config {

@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

@Bean
public S3Client s3Client() {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);

return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/knu/ddip/common/dto/StringTypeResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.knu.ddip.common.dto;

public record StringTypeResponse(
String response
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.knu.ddip.common.exception;

import com.knu.ddip.auth.exception.*;
import com.knu.ddip.common.file.FileStorageException;
import com.knu.ddip.ddipevent.exception.DdipBadRequestException;
import com.knu.ddip.ddipevent.exception.DdipForbiddenException;
import com.knu.ddip.ddipevent.exception.DdipNotFoundException;
import com.knu.ddip.location.exception.LocationNotFoundException;
import com.knu.ddip.user.exception.UserEmailDuplicateException;
import com.knu.ddip.user.exception.UserNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
Expand Down Expand Up @@ -103,4 +106,27 @@ public ResponseEntity<ProblemDetail> handleDdipForbiddenException(DdipForbiddenE
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problemDetail);
}

@ExceptionHandler(UserEmailDuplicateException.class)
public ResponseEntity<ProblemDetail> handleUserEmailDuplicateException(UserEmailDuplicateException e) {

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
problemDetail.setTitle("User Email Duplicate");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
}

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ProblemDetail> handleUserNotFoundException(UserNotFoundException e) {

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
problemDetail.setTitle("User NotFound");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
}

@ExceptionHandler(FileStorageException.class)
public ResponseEntity<ProblemDetail> handleFileStorageException(FileStorageException e) {

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
problemDetail.setTitle("File Storage Exception");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.knu.ddip.common.file;

public class FileStorageException extends RuntimeException {

public FileStorageException(String message) {
super(message);
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/knu/ddip/common/file/FileStorageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.knu.ddip.common.file;

import org.springframework.web.multipart.MultipartFile;

public interface FileStorageService {

String uploadFile(MultipartFile file, String directory);

void deleteFile(String fileUrl);

boolean exists(String fileUrl);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.knu.ddip.common.file.infrastructure;

import com.knu.ddip.common.file.FileStorageException;
import com.knu.ddip.common.file.FileStorageService;
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 software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3FileStorageService implements FileStorageService {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 저장 방식 변경을 염두에 두고 인터페이스 기반으로 개발하신것 좋네요 👍👍


private final S3Client s3Client;

@Value("${cloud.aws.s3.bucket}")
private String bucketName;

@Override
public String uploadFile(MultipartFile file, String directory) {
validateFile(file);

try {
String fileName = generateUniqueFileName(file.getOriginalFilename());
String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String key = String.format("%s/%s/%s", directory, dateDir, fileName);

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build();

s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));

String fileUrl = String.format("https://%s.s3.amazonaws.com/%s", bucketName, key);

log.info("S3 파일 업로드 완료: {} -> {}", file.getOriginalFilename(), fileUrl);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그는 나중에 프로덕션 환경에서 확인하기 위해 작성하신건가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다. 서드파티를 사용하기 때문에 로그 확인용으로 좀 두고 안정성이 확보되면 추후 제거할 예정입니다!

return fileUrl;

} catch (IOException e) {
log.error("S3 파일 업로드 실패: {}", file.getOriginalFilename(), e);
throw new FileStorageException("파일 업로드에 실패했습니다: " + e.getMessage());
} catch (S3Exception e) {
log.error("S3 서비스 오류: {}", e.awsErrorDetails().errorMessage());
throw new FileStorageException("S3 업로드 중 오류가 발생했습니다: " + e.getMessage());
}
}

@Override
public void deleteFile(String fileUrl) {
try {
String key = extractS3Key(fileUrl);

DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build();

s3Client.deleteObject(deleteObjectRequest);
log.info("S3 파일 삭제 완료: {}", fileUrl);

} catch (S3Exception e) {
log.error("S3 파일 삭제 실패: {}", fileUrl, e);
throw new FileStorageException("파일 삭제에 실패했습니다: " + e.getMessage());
}
}

@Override
public boolean exists(String fileUrl) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 exists 메서드는 용도가 뭔가요? 아직 사용하시진 않은 것으로 보이네요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

포트를 만들 때 단순 upload만 만드는 것이 아니라 delete와 exists도 함께 만들었습니다. 현재는 upload기능만 사용하지만, 추후 용량 관리 등을 위해 delete를 사용할 경우에 exists도 아마 사용하지 않을까 싶어 확장성으로 고려해 미리 만들어 두었습니다.
최종적으로 사용하지 않는 것으로 결정 날 경우에 제거할 예정입니다!

try {
String key = extractS3Key(fileUrl);

HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build();

s3Client.headObject(headObjectRequest);
return true;

} catch (NoSuchKeyException e) {
return false;
} catch (S3Exception e) {
log.error("S3 파일 존재 여부 확인 실패: {}", fileUrl, e);
return false;
}
}

private String generateUniqueFileName(String originalFilename) {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HHmmss"));
String uuid = UUID.randomUUID().toString().substring(0, 8);
String extension = getFileExtension(originalFilename);
return String.format("%s_%s%s", timestamp, uuid, extension);
}

private String getFileExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return "";
}
int lastDotIndex = filename.lastIndexOf('.');
return (lastDotIndex == -1) ? "" : filename.substring(lastDotIndex);
}

private String extractS3Key(String fileUrl) {
if (fileUrl.contains(".s3.amazonaws.com/")) {
return fileUrl.substring(fileUrl.indexOf(".s3.amazonaws.com/") + 18);
}

throw new IllegalArgumentException("올바르지 않은 S3 URL 형식입니다: " + fileUrl);
}

private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new FileStorageException("빈 파일은 업로드할 수 없습니다.");
}

// 파일 크기 제한 (50MB)
long maxSize = 50 * 1024 * 1024;
if (file.getSize() > maxSize) {
throw new FileStorageException("파일 크기는 50MB를 초과할 수 없습니다.");
}

// 이미지 파일만 허용
String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
throw new FileStorageException("이미지 파일만 업로드 가능합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.knu.ddip.ddipevent.application.dto;

import com.knu.ddip.ddipevent.domain.PhotoStatus;

public record PhotoFeedbackRequest(
PhotoStatus status,
String feedback
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.knu.ddip.ddipevent.application.dto;

import org.springframework.web.multipart.MultipartFile;

public record PhotoUploadRequest(
MultipartFile photo,
double latitude,
double longitude,
String responderComment
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.knu.ddip.ddipevent.application.dto;

import java.util.UUID;

public record SelectApplicantRequest(
UUID applicantId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ public record UserSummaryDto(
Integer responderMissionCount,
BadgeDto representativeBadge
) {
public static UserSummaryDto fromUserId(String userId) { // TODO : 구현
return new UserSummaryDto(userId, null, null, null, null,
null, null, null);
}
}
Loading
Loading