diff --git a/api-module/src/main/java/hongik/triple/apimodule/application/analysis/AnalysisService.java b/api-module/src/main/java/hongik/triple/apimodule/application/analysis/AnalysisService.java index 425acc4..7c85f09 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/application/analysis/AnalysisService.java +++ b/api-module/src/main/java/hongik/triple/apimodule/application/analysis/AnalysisService.java @@ -1,15 +1,13 @@ package hongik.triple.apimodule.application.analysis; -import hongik.triple.commonmodule.dto.analysis.AnalysisData; -import hongik.triple.commonmodule.dto.analysis.AnalysisRes; -import hongik.triple.commonmodule.dto.analysis.NaverProductDto; -import hongik.triple.commonmodule.dto.analysis.YoutubeVideoDto; +import hongik.triple.commonmodule.dto.analysis.*; import hongik.triple.commonmodule.enumerate.AcneType; import hongik.triple.domainmodule.domain.analysis.Analysis; import hongik.triple.domainmodule.domain.analysis.repository.AnalysisRepository; import hongik.triple.domainmodule.domain.member.Member; import hongik.triple.inframodule.ai.AIClient; import hongik.triple.inframodule.naver.NaverClient; +import hongik.triple.inframodule.s3.S3Client; import hongik.triple.inframodule.youtube.YoutubeClient; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -29,6 +27,7 @@ public class AnalysisService { private final YoutubeClient youtubeClient; private final NaverClient naverClient; private final AnalysisRepository analysisRepository; + private final S3Client s3Client; @Transactional public AnalysisRes performAnalysis(Member member, MultipartFile multipartFile) { @@ -38,7 +37,7 @@ public AnalysisRes performAnalysis(Member member, MultipartFile multipartFile) { } // Business Logic - // TODO: S3 파일 업로드 + String s3_key = s3Client.uploadImage(multipartFile, "skin"); // 피부 분석 AI 모델 호출 AnalysisData analysisData = aiClient.sendPredictRequest(multipartFile); @@ -55,7 +54,7 @@ public AnalysisRes performAnalysis(Member member, MultipartFile multipartFile) { Analysis analysis = Analysis.builder() .member(member) .acneType(analysisData.labelToSkinType()) - .imageUrl("S3 URL or other storage URL") + .imageUrl(s3_key) .isPublic(true) .videoData(videoList) .productData(productList) @@ -65,7 +64,7 @@ public AnalysisRes performAnalysis(Member member, MultipartFile multipartFile) { // Response return new AnalysisRes( saveAnalysis.getAnalysisId(), - saveAnalysis.getImageUrl(), + s3Client.getImage(saveAnalysis.getImageUrl()), saveAnalysis.getIsPublic(), AcneType.valueOf(saveAnalysis.getAcneType()).name(), AcneType.valueOf(saveAnalysis.getAcneType()).getDescription(), @@ -88,7 +87,7 @@ public AnalysisRes getAnalysisDetail(Member member, Long analysisId) { // Response return new AnalysisRes( analysis.getAnalysisId(), - analysis.getImageUrl(), + s3Client.getImage(analysis.getImageUrl()), analysis.getIsPublic(), AcneType.valueOf(analysis.getAcneType()).name(), AcneType.valueOf(analysis.getAcneType()).getDescription(), @@ -99,14 +98,18 @@ public AnalysisRes getAnalysisDetail(Member member, Long analysisId) { ); } - public List getAnalysisListForMainPage() { + public MainLogRes getAnalysisListForMainPage() { // Business Logic - List analyses = analysisRepository.findTop3ByOrderByCreatedAtDesc(); + List analyses = analysisRepository.findTop3ByIsPublicTrueOrderByCreatedAtDesc(); + int comedones = analysisRepository.countByAcneTypeAndIsPublicTrue("COMEDONES"); + int pustules = analysisRepository.countByAcneTypeAndIsPublicTrue("PUSTULES"); + int papules = analysisRepository.countByAcneTypeAndIsPublicTrue("PAPULES"); + int follicultis = analysisRepository.countByAcneTypeAndIsPublicTrue("FOLLICULITIS"); // Response - return analyses.stream().map(analysis -> new AnalysisRes( + List analysisList = analyses.stream().map(analysis -> new AnalysisRes( analysis.getAnalysisId(), - analysis.getImageUrl(), + s3Client.getImage(analysis.getImageUrl()), analysis.getIsPublic(), AcneType.valueOf(analysis.getAcneType()).name(), AcneType.valueOf(analysis.getAcneType()).getDescription(), @@ -115,6 +118,8 @@ public List getAnalysisListForMainPage() { analysis.getVideoData(), analysis.getProductData() )).toList(); + + return MainLogRes.from(comedones, pustules, papules, follicultis, analysisList); } /** @@ -148,7 +153,7 @@ public Page getAnalysisPaginationForLogPage(String acneType, Pageab // Response return analysisPage.map(analysis -> new AnalysisRes( analysis.getAnalysisId(), - analysis.getImageUrl(), + s3Client.getImage(analysis.getImageUrl()), analysis.getIsPublic(), AcneType.valueOf(analysis.getAcneType()).name(), AcneType.valueOf(analysis.getAcneType()).getDescription(), @@ -195,7 +200,7 @@ public Page getAnalysisListForMyPage(Member member, String acneType // Response return analysisPage.map(analysis -> new AnalysisRes( analysis.getAnalysisId(), - analysis.getImageUrl(), + s3Client.getImage(analysis.getImageUrl()), analysis.getIsPublic(), AcneType.valueOf(analysis.getAcneType()).name(), AcneType.valueOf(analysis.getAcneType()).getDescription(), @@ -205,4 +210,26 @@ public Page getAnalysisListForMyPage(Member member, String acneType analysis.getProductData() )); } + + /* + 피플즈 로그 개별 화면 조회 + */ + public AnalysisRes getLogDetail(Long analysisId) { + // Validation + Analysis analysis = analysisRepository.findById(analysisId) + .orElseThrow(() -> new IllegalArgumentException("Analysis not found with id: " + analysisId)); + + // Response + return new AnalysisRes( + analysis.getAnalysisId(), + s3Client.getImage(analysis.getImageUrl()), + analysis.getIsPublic(), + AcneType.valueOf(analysis.getAcneType()).name(), + AcneType.valueOf(analysis.getAcneType()).getDescription(), + AcneType.valueOf(analysis.getAcneType()).getCareMethod(), + AcneType.valueOf(analysis.getAcneType()).getGuide(), + analysis.getVideoData(), + analysis.getProductData() + ); + } } diff --git a/api-module/src/main/java/hongik/triple/apimodule/presentation/analysis/AnalysisController.java b/api-module/src/main/java/hongik/triple/apimodule/presentation/analysis/AnalysisController.java index 9e665f2..4d729ff 100644 --- a/api-module/src/main/java/hongik/triple/apimodule/presentation/analysis/AnalysisController.java +++ b/api-module/src/main/java/hongik/triple/apimodule/presentation/analysis/AnalysisController.java @@ -5,6 +5,7 @@ import hongik.triple.apimodule.global.security.PrincipalDetails; import hongik.triple.commonmodule.dto.analysis.AnalysisRes; import hongik.triple.commonmodule.dto.survey.SurveyRes; +import hongik.triple.inframodule.s3.S3Client; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -25,6 +26,7 @@ public class AnalysisController { private final AnalysisService analysisService; + private final S3Client s3Client; @PostMapping("/perform") @Operation(summary = "피부 이미지 분석", description = "사용자에게 피부 이미지를 전달받아, 분석 결과를 조회합니다.") @@ -41,6 +43,7 @@ public ApplicationResponse performAnalysis(@AuthenticationPrincipal Principal } @GetMapping("/main") + @Operation(summary = "[홈화면] 피플즈 로그 썸네일 조회", description = "홈화면의 피플즈 로그에 노출되는 상위 3개의 분석 이미지를 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "AnceLog Main Page 에서 노출할 피부 분석 이미지 결과 목록", @@ -54,6 +57,7 @@ public ApplicationResponse getAnalysisListForMainPage() { } @GetMapping("/my") + @Operation(summary = "나의 진단로그 리스트 조회", description = "나의 진단로그 페이지의 리스트를 조회합니다.") public ApplicationResponse getAnalysisListForMyPage(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestParam(name = "type") String acneType, @PageableDefault(size = 4) Pageable pageable) { @@ -61,13 +65,36 @@ public ApplicationResponse getAnalysisListForMyPage(@AuthenticationPrincipal } @GetMapping("/detail/{analysisId}") + @Operation(summary = "나의 진단로그 상세페이지 조회", description = "나의 진단로그 페이지의 상세 페이지를 조회합니다.") public ApplicationResponse getAnalysisDetail(@AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long analysisId) { return ApplicationResponse.ok(analysisService.getAnalysisDetail(principalDetails.getMember(), analysisId)); } @GetMapping("/log") + @Operation(summary = "피플즈 로그 리스트 조회", description = "피플즈 로그 페이지의 리스트를 조회합니다.") public ApplicationResponse getAnalysisPaginationForLogPage(@RequestParam(name = "type") String acneType, @PageableDefault(size = 4) Pageable pageable) { return ApplicationResponse.ok(analysisService.getAnalysisPaginationForLogPage(acneType, pageable)); } + + @GetMapping("/log/{analysisId}") + @Operation(summary = "피플즈 로그 상세페이지 조회", description = "피플즈 로그 페이지의 상세 페이지를 조회합니다.") + public ApplicationResponse getLogDetail(@PathVariable Long analysisId) { + return ApplicationResponse.ok(analysisService.getLogDetail(analysisId)); + } + + @PostMapping("/image") + @Operation(summary = "이미지 업로드", description = "S3에 이미지를 업로드하는 API 입니다. (어드민용)") + public ApplicationResponse upload(@RequestPart MultipartFile file, @RequestParam(name = "dir") String dir) { + + return ApplicationResponse.ok(s3Client.uploadImage(file, dir)); + } + + @DeleteMapping("/image") + @Operation(summary = "이미지 삭제", description = "S3에서 이미지를 삭제하는 API 입니다. (어드민용)") + public ApplicationResponse delete(@RequestParam String key) { + + s3Client.deleteImage(key); + return ApplicationResponse.ok("이미지가 삭제되었습니다."); + } } diff --git a/common-module/src/main/java/hongik/triple/commonmodule/dto/analysis/MainLogRes.java b/common-module/src/main/java/hongik/triple/commonmodule/dto/analysis/MainLogRes.java new file mode 100644 index 0000000..ac40632 --- /dev/null +++ b/common-module/src/main/java/hongik/triple/commonmodule/dto/analysis/MainLogRes.java @@ -0,0 +1,21 @@ +package hongik.triple.commonmodule.dto.analysis; + +import java.util.List; + +public record MainLogRes( + int comedones, + int pustules, + int papules, + int follicultis, + List analysisRes +) { + public static MainLogRes from(int comedones, int pustules, int papules, int follicultis, List analysisRes) { + return new MainLogRes( + comedones, + pustules, + papules, + follicultis, + analysisRes + ); + } +} diff --git a/common-module/src/main/java/hongik/triple/commonmodule/exception/ErrorCode.java b/common-module/src/main/java/hongik/triple/commonmodule/exception/ErrorCode.java index e98d180..5a8bfda 100644 --- a/common-module/src/main/java/hongik/triple/commonmodule/exception/ErrorCode.java +++ b/common-module/src/main/java/hongik/triple/commonmodule/exception/ErrorCode.java @@ -20,7 +20,17 @@ public enum ErrorCode { ALREADY_DELETE_EXCEPTION(HttpStatus.BAD_REQUEST, 2004, "이미 삭제된 리소스입니다."), FORBIDDEN_EXCEPTION(HttpStatus.FORBIDDEN, 2005, "인가되지 않는 요청입니다."), ALREADY_EXIST_EXCEPTION(HttpStatus.BAD_REQUEST, 2006, "이미 존재하는 리소스입니다."), - INVALID_SORT_EXCEPTION(HttpStatus.BAD_REQUEST, 2007, "올바르지 않은 정렬 값입니다."); + INVALID_SORT_EXCEPTION(HttpStatus.BAD_REQUEST, 2007, "올바르지 않은 정렬 값입니다."), + + // 3000: Image Error + EMPTY_FILE_EXCEPTION(HttpStatus.BAD_REQUEST, 3000, "파일이 비어있습니다."), + INVALID_FILENAME_EXCEPTION(HttpStatus.BAD_REQUEST, 3001, "파일 이름이 유효하지 않습니다."), + FILE_IO_EXCEPTION(HttpStatus.BAD_REQUEST, 3002, "파일 입출력 처리 중 예상치 못한 오류가 발생했습니다."), + FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, 3003, "파일 업로드에 실패하였습니다."), + EMPTY_S3_KEY_EXCEPTION(HttpStatus.BAD_REQUEST, 3004, "S3 key 값이 비어있습니다."), + NOT_FOUND_S3_EXCEPTION(HttpStatus.NOT_FOUND, 3005, "존재하지 않는 S3 객체입니다."), + FAILED_DELETE_FILE(HttpStatus.INTERNAL_SERVER_ERROR, 3006, "이미지 삭제에 실패하였습니다."), + NOT_ALLOWED_FILE_EXTENSION(HttpStatus.BAD_REQUEST, 3007, "올바르지 않은 파일 확장자입니다."); private final HttpStatus httpStatus; private final Integer code; diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/domain/analysis/repository/AnalysisRepository.java b/domain-module/src/main/java/hongik/triple/domainmodule/domain/analysis/repository/AnalysisRepository.java index e2445a5..7247cec 100644 --- a/domain-module/src/main/java/hongik/triple/domainmodule/domain/analysis/repository/AnalysisRepository.java +++ b/domain-module/src/main/java/hongik/triple/domainmodule/domain/analysis/repository/AnalysisRepository.java @@ -11,7 +11,8 @@ public interface AnalysisRepository extends JpaRepository { // 메인 페이지용 - List findTop3ByOrderByCreatedAtDesc(); + List findTop3ByIsPublicTrueOrderByCreatedAtDesc(); + int countByAcneTypeAndIsPublicTrue(String acneType); // 피플즈 로그 페이지용 - 전체 공개 분석 조회 Page findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable); diff --git a/infra-module/build.gradle b/infra-module/build.gradle index d2e96ae..3dff72f 100644 --- a/infra-module/build.gradle +++ b/infra-module/build.gradle @@ -10,6 +10,12 @@ dependencies { // WebClient implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // s3 + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.766' + + // web + implementation 'org.springframework.boot:spring-boot-starter-web' } tasks.register("prepareKotlinBuildScriptModel"){} \ No newline at end of file diff --git a/infra-module/src/main/java/hongik/triple/inframodule/config/S3Config.java b/infra-module/src/main/java/hongik/triple/inframodule/config/S3Config.java new file mode 100644 index 0000000..0b9d92a --- /dev/null +++ b/infra-module/src/main/java/hongik/triple/inframodule/config/S3Config.java @@ -0,0 +1,34 @@ +package hongik.triple.inframodule.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@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 AmazonS3 amazonS3() { + + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} + diff --git a/infra-module/src/main/java/hongik/triple/inframodule/s3/S3Client.java b/infra-module/src/main/java/hongik/triple/inframodule/s3/S3Client.java index 037e394..aaeeaec 100644 --- a/infra-module/src/main/java/hongik/triple/inframodule/s3/S3Client.java +++ b/infra-module/src/main/java/hongik/triple/inframodule/s3/S3Client.java @@ -1,18 +1,171 @@ package hongik.triple.inframodule.s3; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import hongik.triple.commonmodule.exception.ApplicationException; +import hongik.triple.commonmodule.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j @Component public class S3Client { + @Value("${cloud.aws.s3.bucket}") + private String bucket; + @Value("${cloud.aws.baseUrl}") + private String baseUrl; + + private final AmazonS3 amazonS3; + + /** + * 이미지 업로드 + */ + public String uploadImage(MultipartFile file, String dirName) { + validateFile(file); + validateImageExtension(file); + + String key = generateFileKey(file.getOriginalFilename(), dirName); + ObjectMetadata metadata = createMetadata(file); + uploadToS3(file, key, metadata); + + return key; + } + /** - * S3 Object에 대한 presigned URL을 생성 - * - * @param fileName 파일 이름 - * @param isUpload true면 업로드를 위한 presigned URL, false면 다운로드를 위한 presigned URL - * @return the presigned URL as a String + * 이미지 조회 + */ + public String getImage(String key) { + validateKey(key); + validateObjectExists(key); + + return baseUrl + "/" + key; + } + + /** + * 이미지 삭제 + */ + public void deleteImage(String key) { + validateKey(key); + validateObjectExists(key); + + deleteObjectFromS3(key); + } + + /* + file 유효성 검사 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ApplicationException(ErrorCode.EMPTY_FILE_EXCEPTION); + } + if (file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) { + throw new ApplicationException(ErrorCode.INVALID_FILENAME_EXCEPTION); + } + } + + /* + file 확장자 검사 + */ + private void validateImageExtension(MultipartFile file) { + String originalName = file.getOriginalFilename(); + String extension = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(); + + // 허용 확장자 목록 + List allowed = List.of("jpg", "jpeg", "png", "gif", "webp"); + + if (!allowed.contains(extension)) { + throw new ApplicationException(ErrorCode.NOT_ALLOWED_FILE_EXTENSION); + } + } + + /* + key 생성 + */ + private String generateFileKey(String originalName, String dirName) { + String date = LocalDate.now().toString(); + String uuid = UUID.randomUUID().toString(); + + return String.format("%s/%s_%s_%s", dirName, date, uuid, originalName); + } + + /* + Metadata 생성 */ - public String getPresignedUrl(String fileName, Boolean isUpload) { - return null; + private ObjectMetadata createMetadata(MultipartFile file) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + metadata.setContentLength(file.getSize()); + return metadata; } + + /* + S3 업로드 + */ + private void uploadToS3(MultipartFile file, String key, ObjectMetadata metadata) { + try (InputStream input = file.getInputStream()) { + PutObjectRequest request = new PutObjectRequest(bucket, key, input, metadata); + amazonS3.putObject(request); + + } catch (IOException e) { + throw new ApplicationException(ErrorCode.FILE_IO_EXCEPTION); + + } catch (AmazonServiceException e) { + log.error("AWS Service 에러: {}", e.getErrorMessage()); + throw new ApplicationException(ErrorCode.FAILED_UPLOAD_FILE); + + } catch (SdkClientException e) { + log.error("AWS Client 에러: {}", e.getMessage()); + throw new ApplicationException(ErrorCode.FAILED_UPLOAD_FILE); + } + } + + /* + key 유효성 검사 + */ + private void validateKey(String key) { + if (key == null || key.isBlank()) { + throw new ApplicationException(ErrorCode.EMPTY_S3_KEY_EXCEPTION); + } + } + + /* + S3 객체 유효성 검사 + */ + private void validateObjectExists(String key) { + if (!amazonS3.doesObjectExist(bucket, key)) { + throw new ApplicationException(ErrorCode.NOT_FOUND_S3_EXCEPTION); + } + } + + /* + S3 이미지 객체 삭제 + */ + private void deleteObjectFromS3(String key) { + try { + amazonS3.deleteObject(bucket, key); + } catch (AmazonServiceException e) { + log.error("이미지 삭제 중 AWS Service 에러 발생: {}", e.getErrorMessage()); + throw new ApplicationException(ErrorCode.FAILED_DELETE_FILE); + } catch (SdkClientException e) { + log.error("이미지 삭제 중 AWS Client 에러 발생: {}", e.getMessage()); + throw new ApplicationException(ErrorCode.FAILED_DELETE_FILE); + } + } + }