Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,207 @@

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.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.youtube.YoutubeClient;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalysisService {

private final AIClient aiClient;
private final YoutubeClient youtubeClient;
private final NaverClient naverClient;
private final AnalysisRepository analysisRepository;

@Transactional
public AnalysisRes performAnalysis(Member member, MultipartFile multipartFile) {
// Validation
if(multipartFile.isEmpty() || multipartFile.getSize() == 0) {
throw new IllegalArgumentException("File is empty");
}

// Business Logic
// TODO: S3 파일 업로드

// 피부 분석 AI 모델 호출
AnalysisData analysisData = aiClient.sendPredictRequest(multipartFile);
System.out.println(analysisData);

// Analysis.builder()
// .member(member)
// .skinType(analysisData.labelToSkinType())
// .build();
// 진단 결과를 기반으로 피부 관리 영상 추천 (유튜브 API)
List<YoutubeVideoDto> videoList =
youtubeClient.searchVideos(analysisData.labelToSkinType().getDescription() + " 피부 관리", 3);

// 진단 결과를 기반으로 맞춤형 제품 추천 (네이버 쇼핑 API)
List<NaverProductDto> productList =
naverClient.searchProducts(analysisData.labelToSkinType().getDescription() + " 피부 관리", 3);

// DB 저장
Analysis analysis = Analysis.builder()
.member(member)
.acneType(analysisData.labelToSkinType())
.imageUrl("S3 URL or other storage URL")
.isPublic(true)
.videoData(videoList)
.productData(productList)
.build();
Analysis saveAnalysis = analysisRepository.save(analysis);

// Response
return new AnalysisRes(); // Replace with actual response data
return new AnalysisRes(
saveAnalysis.getAnalysisId(),
saveAnalysis.getImageUrl(),
saveAnalysis.getIsPublic(),
AcneType.valueOf(saveAnalysis.getAcneType()).name(),
AcneType.valueOf(saveAnalysis.getAcneType()).getDescription(),
AcneType.valueOf(saveAnalysis.getAcneType()).getCareMethod(),
AcneType.valueOf(saveAnalysis.getAcneType()).getGuide(),
videoList,
productList
);
}

public AnalysisRes getAnalysisDetail(Member member, Long analysisId) {
// Validation
Analysis analysis = analysisRepository.findById(analysisId)
.orElseThrow(() -> new IllegalArgumentException("Analysis not found with id: " + analysisId));
// Analysis가 요청한 사용자의 분석 결과인지 확인
if(!analysis.getMember().getMemberId().equals(member.getMemberId())) {
throw new IllegalArgumentException("Unauthorized access to analysis with id: " + analysisId);
}

// Response
return new AnalysisRes(
analysis.getAnalysisId(),
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()
);
}

public List<AnalysisRes> getAnalysisListForMainPage() {
// Business Logic
List<Analysis> analyses = analysisRepository.findTop3ByOrderByCreatedAtDesc();

// Response
return analyses.stream().map(analysis -> new AnalysisRes(
analysis.getAnalysisId(),
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()
)).toList();
}

/**
* 피플즈 로그 페이지용 공개된 분석 기록 페이지네이션 조회
* @param acneType 여드름 타입 (ALL인 경우 전체 조회)
* @param pageable 페이지 정보
* @return 페이지네이션된 공개 분석 기록 리스트
*/
public Page<AnalysisRes> getAnalysisPaginationForLogPage(String acneType, Pageable pageable) {
// Validation
// acneType이 ALL이 아닌 경우 유효성 검증
if (!"ALL".equalsIgnoreCase(acneType)) {
try {
AcneType.valueOf(acneType.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid acne type: " + acneType);
}
}

// Business Logic
Page<Analysis> analysisPage;

// "ALL"인 경우 전체 공개 분석 조회, 아니면 타입별 공개 분석 조회
if ("ALL".equalsIgnoreCase(acneType)) {
analysisPage = analysisRepository.findByIsPublicTrueOrderByCreatedAtDesc(pageable);
} else {
analysisPage = analysisRepository.findByIsPublicTrueAndAcneTypeOrderByCreatedAtDesc(
acneType.toUpperCase(), pageable);
}

// Response
return analysisPage.map(analysis -> new AnalysisRes(
analysis.getAnalysisId(),
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()
));
}

/**
* 마이페이지용 내 분석 기록 페이지네이션 조회
* @param member 현재 로그인한 회원
* @param acneType 여드름 타입 (ALL인 경우 전체 조회)
* @param pageable 페이지 정보
* @return 페이지네이션된 내 분석 기록 리스트
*/
public Page<AnalysisRes> getAnalysisListForMyPage(Member member, String acneType, Pageable pageable) {
// Validation
if (member == null) {
throw new IllegalArgumentException("Member cannot be null");
}

// acneType이 ALL이 아닌 경우 유효성 검증
if (!"ALL".equalsIgnoreCase(acneType)) {
try {
AcneType.valueOf(acneType.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid acne type: " + acneType);
}
}

// Business Logic
Page<Analysis> analysisPage;

// "ALL"인 경우 내 전체 분석 조회, 아니면 타입별 내 분석 조회
if ("ALL".equalsIgnoreCase(acneType)) {
analysisPage = analysisRepository.findByMemberOrderByCreatedAtDesc(member, pageable);
} else {
analysisPage = analysisRepository.findByMemberAndAcneTypeOrderByCreatedAtDesc(
member, acneType.toUpperCase(), pageable);
}

// Response
return analysisPage.map(analysis -> new AnalysisRes(
analysis.getAnalysisId(),
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()
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public SurveyRes registerSurvey(SurveyReq request) {
.surveyId(savedSurvey.getSurveyId())
.memberId(savedSurvey.getMember().getMemberId())
.memberName(savedSurvey.getMember().getName())
.skinType(savedSurvey.getSkinType())
.skinType(SkinType.valueOf(savedSurvey.getSkinType()))
.body((Map<String, Object>) savedSurvey.getBody())
.createdAt(savedSurvey.getCreatedAt())
.modifiedAt(savedSurvey.getModifiedAt())
Expand Down Expand Up @@ -146,17 +146,17 @@ private SkinType calculateSkinType(Map<String, Object> answers) {

// 점수 기반 피부 타입 결정 (심각도 순으로 우선순위)
if (averageScore <= 2.0) {
return SkinType.NORMAL;
return SkinType.OILY;
} else if (folliculitisScore >= 8) { // 모낭염 문항 평균 4점 이상
return SkinType.FOLLICULITIS;
return SkinType.OILY;
} else if (pustuleScore >= 8) { // 화농성 문항 평균 4점 이상
return SkinType.PUSTULES;
return SkinType.OILY;
} else if (inflammationScore >= 15) { // 염증성 문항 평균 3.75점 이상
return SkinType.PAPULES;
return SkinType.OILY;
} else if (comedoneScore >= 12) { // 좁쌀 문항 평균 3점 이상
return SkinType.COMEDONES;
return SkinType.OILY;
} else {
return SkinType.NORMAL;
return SkinType.OILY;
}
}

Expand Down Expand Up @@ -273,16 +273,12 @@ private int calculateTotalScore(Map<String, Object> body) {

private String generateRecommendation(SkinType skinType) {
switch (skinType) {
case NORMAL:
case OILY:
return "현재 피부 상태가 양호합니다. 기본적인 세안과 보습 관리를 지속하시고, 자외선 차단제를 꾸준히 사용하세요.";
case COMEDONES:
case COMBINATION:
return "좁쌀여드름이 있습니다. BHA나 살리실산 성분의 각질 제거 제품을 사용하고, 논코메도제닉 제품으로 모공 관리에 집중하세요.";
case PUSTULES:
case DRY:
return "화농성 여드름이 있습니다. 벤조일 퍼옥사이드나 항생제 성분이 포함된 제품을 사용하고, 피부과 전문의 상담을 받아보세요.";
case PAPULES:
return "염증성 여드름이 있습니다. 자극적인 제품 사용을 피하고 니아신아마이드, 아젤라산 등의 진정 성분으로 관리하며, 피부과 치료를 권장합니다.";
case FOLLICULITIS:
return "모낭염이 의심됩니다. 면도 후 항균 토너를 사용하고, 청결한 관리와 함께 피부과 전문의 진료를 받으시기 바랍니다.";
default:
return "정확한 진단을 위해 피부과 전문의와 상담을 받아보세요.";
}
Expand Down Expand Up @@ -481,12 +477,12 @@ private SurveyRes convertToSurveyRes(Survey survey) {
.surveyId(survey.getSurveyId())
.memberId(survey.getMember().getMemberId())
.memberName(survey.getMember().getName())
.skinType(survey.getSkinType())
.skinType(SkinType.valueOf(survey.getSkinType()))
.body((Map<String, Object>) survey.getBody())
.createdAt(survey.getCreatedAt())
.modifiedAt(survey.getModifiedAt())
.totalScore(calculateTotalScore((Map<String, Object>) survey.getBody()))
.recommendation(generateRecommendation(survey.getSkinType()))
.recommendation(generateRecommendation(SkinType.valueOf(survey.getSkinType())))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import hongik.triple.apimodule.application.analysis.AnalysisService;
import hongik.triple.apimodule.global.common.ApplicationResponse;
import hongik.triple.apimodule.global.security.PrincipalDetails;
import hongik.triple.commonmodule.dto.analysis.AnalysisReq;
import hongik.triple.commonmodule.dto.analysis.AnalysisRes;
import hongik.triple.commonmodule.dto.survey.SurveyRes;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -13,6 +12,8 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -35,8 +36,8 @@ public class AnalysisController {
@ApiResponse(responseCode = "500",
description = "서버 오류")
})
public ApplicationResponse<?> performAnalysis(@RequestPart(value = "file") MultipartFile multipartFile) {
return ApplicationResponse.ok(analysisService.performAnalysis(null, multipartFile));
public ApplicationResponse<?> performAnalysis(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestPart(value = "file") MultipartFile multipartFile) {
return ApplicationResponse.ok(analysisService.performAnalysis(principalDetails.getMember(), multipartFile));
}

@GetMapping("/main")
Expand All @@ -49,21 +50,24 @@ public ApplicationResponse<?> performAnalysis(@RequestPart(value = "file") Multi
description = "서버 오류")
})
public ApplicationResponse<?> getAnalysisListForMainPage() {
return ApplicationResponse.ok();
return ApplicationResponse.ok(analysisService.getAnalysisListForMainPage());
}

@GetMapping("/my")
public ApplicationResponse<?> getAnalysisListForMyPage() {
return ApplicationResponse.ok();
public ApplicationResponse<?> getAnalysisListForMyPage(@AuthenticationPrincipal PrincipalDetails principalDetails,
@RequestParam(name = "type") String acneType,
@PageableDefault(size = 4) Pageable pageable) {
return ApplicationResponse.ok(analysisService.getAnalysisListForMyPage(principalDetails.getMember(), acneType, pageable));
}

@GetMapping("/detail/{analysisId}")
public ApplicationResponse<?> getAnalysisDetail(@PathVariable Long analysisId) {
return ApplicationResponse.ok();
public ApplicationResponse<?> getAnalysisDetail(@AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long analysisId) {
return ApplicationResponse.ok(analysisService.getAnalysisDetail(principalDetails.getMember(), analysisId));
}

@GetMapping("/log")
public ApplicationResponse<?> getAnalysisPaginationForLogPage() {
return ApplicationResponse.ok();
public ApplicationResponse<?> getAnalysisPaginationForLogPage(@RequestParam(name = "type") String acneType,
@PageableDefault(size = 4) Pageable pageable) {
return ApplicationResponse.ok(analysisService.getAnalysisPaginationForLogPage(acneType, pageable));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package hongik.triple.commonmodule.dto.analysis;

import com.fasterxml.jackson.annotation.JsonProperty;
import hongik.triple.commonmodule.enumerate.SkinType;
import hongik.triple.commonmodule.enumerate.AcneType;

import java.util.List;

Expand All @@ -15,12 +15,12 @@ public record AnalysisData(
List<Double> scores
) {

public SkinType labelToSkinType() {
public AcneType labelToSkinType() {
return switch (this.predictionLabel) {
case "Comedones" -> SkinType.COMEDONES;
case "Pustules" -> SkinType.PUSTULES;
case "Papules" -> SkinType.PAPULES;
case "Folliculitis" -> SkinType.FOLLICULITIS;
case "Comedones" -> AcneType.COMEDONES;
case "Pustules" -> AcneType.PUSTULES;
case "Papules" -> AcneType.PAPULES;
case "Folliculitis" -> AcneType.FOLLICULITIS;
default -> throw new IllegalArgumentException("Unknown label: " + this.predictionLabel);
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
package hongik.triple.commonmodule.dto.analysis;

import java.util.List;

public record AnalysisRes(
Long analysisId,
String imageUrl,
Boolean isPublic,
String acneType,
String description,
String careMethod,
String guide,
List<YoutubeVideoDto> videoList,
List<NaverProductDto> productList
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package hongik.triple.commonmodule.dto.analysis;

public record NaverProductDto(
String productId,
String productName,
String productUrl,
Integer productPrice,
String productImage,
String categoryName,
String mallName,
String brand
) {}
Loading