diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 88d166f0..cc17b4a3 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -63,7 +63,7 @@ jobs: username: ubuntu host: ${{ secrets.DEV_EC2_HOST }} key: ${{ secrets.DEV_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,DEV_AWS_ACCESS_KEY_ID,DEV_AWS_SECRET_ACCESS_KEY,AWS_REGION,DEV_S3_BUCKET,DEV_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64 + envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,DEV_AWS_ACCESS_KEY_ID,DEV_AWS_SECRET_ACCESS_KEY,AWS_REGION,DEV_S3_BUCKET,DEV_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH script: | export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app @@ -99,6 +99,11 @@ jobs: export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} + export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} + export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} + export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} + export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + sudo mkdir -p /home/ubuntu/secrets echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 76d4cb70..c10c6f02 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -73,7 +73,7 @@ jobs: username: ubuntu host: ${{ secrets.PROD_EC2_HOST }} key: ${{ secrets.PROD_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,SPRING_PROFILES_ACTIVE,PROD_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,PROD_KAKAO_CLIENT_ID,PROD_KAKAO_CLIENT_SECRET,PROD_APPLE_CLIENT_ID,PROD_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,PROD_AWS_ACCESS_KEY_ID,PROD_AWS_SECRET_ACCESS_KEY,AWS_REGION,PROD_S3_BUCKET,PROD_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64 + envs: DOCKERHUB_USERNAME,SPRING_PROFILES_ACTIVE,PROD_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,PROD_KAKAO_CLIENT_ID,PROD_KAKAO_CLIENT_SECRET,PROD_APPLE_CLIENT_ID,PROD_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,PROD_AWS_ACCESS_KEY_ID,PROD_AWS_SECRET_ACCESS_KEY,AWS_REGION,PROD_S3_BUCKET,PROD_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH script: | export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=prod-app @@ -109,6 +109,11 @@ jobs: export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} + export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} + export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} + export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} + export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + sudo mkdir -p /home/ubuntu/secrets echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothAiController.java b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothAiController.java new file mode 100644 index 00000000..65eb7165 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothAiController.java @@ -0,0 +1,74 @@ +package org.clokey.domain.cloth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.cloth.dto.request.ClothDetectRequest; +import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; +import org.clokey.domain.cloth.dto.request.ClothInfoExtractRequest; +import org.clokey.domain.cloth.dto.request.HistoryStyleInferenceRequest; +import org.clokey.domain.cloth.dto.response.ClothDetectResponse; +import org.clokey.domain.cloth.dto.response.ClothImagesPresignedUrlResponse; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractResponse; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceResponse; +import org.clokey.domain.cloth.service.ClothAiService; +import org.clokey.response.BaseResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/cloth-ai") +@RequiredArgsConstructor +@Tag(name = "17. 옷 AI API", description = "옷 AI 관련 API입니다.") +@Validated +public class ClothAiController { + + private final ClothAiService clothAiService; + + @PostMapping("/images") + @Operation( + operationId = "ClothAi_getClothUploadPresignedUrl", + summary = "옷 이미지 업로드용 presignedUrl 발급", + description = "옷 이미지 업로드용 presignedUrl을 발급합니다.") + public BaseResponse getClothUploadPresignedUrl( + @Valid @RequestBody ClothImagesUploadRequest request) { + ClothImagesPresignedUrlResponse response = + clothAiService.getClothUploadPresignedUrls(request); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); + } + + @PostMapping("/extract") + @Operation( + operationId = "ClothAi_extractClothInfo", + summary = "옷 정보 추출", + description = "옷 이미지 URL을 기반으로 AI 서버에서 옷 정보를 추출합니다.") + public BaseResponse extractClothInfo( + @Valid @RequestBody ClothInfoExtractRequest request) { + ClothInfoExtractResponse response = clothAiService.extractClothInfo(request); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); + } + + @PostMapping("/history-style") + @Operation( + operationId = "ClothAi_inferHistoryStyle", + summary = "기록 사진 스타일 추론", + description = "기록 이미지 URL을 통해 스타일을 추론합니다.") + public BaseResponse inferHistoryStyle( + @Valid @RequestBody HistoryStyleInferenceRequest request) { + HistoryStyleInferenceResponse response = clothAiService.inferHistoryStyle(request); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); + } + + @PostMapping("/detect") + @Operation( + operationId = "ClothAi_detectClothes", + summary = "사진에서 옷 탐지", + description = "사진에서 내부의 옷들을 탐지합니다.") + public BaseResponse detectClothes( + @Valid @RequestBody ClothDetectRequest request) { + ClothDetectResponse response = clothAiService.detectClothes(request); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java index 6b6c9d68..b80acd4e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java @@ -9,7 +9,6 @@ import org.clokey.cloth.enums.Season; import org.clokey.code.GlobalBaseSuccessCode; import org.clokey.domain.cloth.dto.request.ClothCreateRequests; -import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; import org.clokey.domain.cloth.dto.request.ClothUpdateRequest; import org.clokey.domain.cloth.dto.response.*; import org.clokey.domain.cloth.service.ClothService; @@ -29,18 +28,6 @@ public class ClothController { private final ClothService clothService; - @PostMapping("/images") - @Operation( - operationId = "Cloth_getClothUploadPresignedUrl", - summary = "옷 이미지 업로드용 presignedUrl 발급", - description = "옷 이미지 업로드용 presignedUrl을 발급합니다.") - public BaseResponse getClothUploadPresignedUrl( - @Valid @RequestBody ClothImagesUploadRequest request) { - ClothImagesPresignedUrlResponse response = - clothService.getClothUploadPresignedUrls(request); - return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); - } - @PostMapping @Operation(operationId = "Cloth_createClothes", summary = "옷 생성", description = "새로운 옷을 생성합니다.") public BaseResponse createClothes( diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectAiRequestDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectAiRequestDTO.java new file mode 100644 index 00000000..ea259bb8 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectAiRequestDTO.java @@ -0,0 +1,5 @@ +package org.clokey.domain.cloth.dto.request; + +import java.util.List; + +public record ClothDetectAiRequestDTO(String imageUrl, List presignedUrls) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectRequest.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectRequest.java new file mode 100644 index 00000000..39674e9f --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothDetectRequest.java @@ -0,0 +1,10 @@ +package org.clokey.domain.cloth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "사진에서 옷 탐지 요청") +public record ClothDetectRequest( + @NotBlank(message = "이미지 URL은 비워둘 수 없습니다.") + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String imageUrl) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractAiRequestDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractAiRequestDTO.java new file mode 100644 index 00000000..dbbabc03 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractAiRequestDTO.java @@ -0,0 +1,6 @@ +package org.clokey.domain.cloth.dto.request; + +import java.util.List; + +public record ClothInfoExtractAiRequestDTO( + List clothImageUrls, List presignedUrls) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractRequest.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractRequest.java new file mode 100644 index 00000000..f466b4d3 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/ClothInfoExtractRequest.java @@ -0,0 +1,17 @@ +package org.clokey.domain.cloth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +@Schema(description = "옷 정보 추출 요청") +public record ClothInfoExtractRequest( + @NotEmpty(message = "옷 이미지 URL 목록은 비워둘 수 없습니다.") + @Size(max = 10, message = "옷 이미지 URL은 최대 10개까지 입력할 수 있습니다.") + @Schema( + description = "옷 이미지 URL 목록 (최대 10개)", + example = + "[\"https://example.com/cloth1.jpg\", \"https://example.com/cloth2.jpg\"]") + List<@NotBlank(message = "옷 이미지 URL은 비워둘 수 없습니다.") String> clothImageUrls) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceAiRequestDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceAiRequestDTO.java new file mode 100644 index 00000000..6ff3cd73 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceAiRequestDTO.java @@ -0,0 +1,3 @@ +package org.clokey.domain.cloth.dto.request; + +public record HistoryStyleInferenceAiRequestDTO(String historyImageUrl) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceRequest.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceRequest.java new file mode 100644 index 00000000..b9a3a754 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/HistoryStyleInferenceRequest.java @@ -0,0 +1,10 @@ +package org.clokey.domain.cloth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "기록 사진 스타일 추론 요청") +public record HistoryStyleInferenceRequest( + @NotBlank(message = "기록 이미지 URL은 비워둘 수 없습니다.") + @Schema(description = "기록 이미지 URL", example = "https://example.com/history.jpg") + String historyImageUrl) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectAiResponseDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectAiResponseDTO.java new file mode 100644 index 00000000..f5fbeab9 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectAiResponseDTO.java @@ -0,0 +1,10 @@ +package org.clokey.domain.cloth.dto.response; + +import java.util.List; +import org.clokey.cloth.enums.Season; + +public record ClothDetectAiResponseDTO(List payloads) { + + public record Payload( + String clothImageUrl, Season season, Long categoryId, String categoryName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectResponse.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectResponse.java new file mode 100644 index 00000000..57c4675c --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothDetectResponse.java @@ -0,0 +1,21 @@ +package org.clokey.domain.cloth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.clokey.cloth.enums.Season; + +@Schema(description = "사진에서 옷 탐지 응답") +public record ClothDetectResponse(@Schema(description = "옷 정보 목록") List payloads) { + + public static ClothDetectResponse of(List payloads) { + return new ClothDetectResponse(payloads); + } + + @Schema(name = "ClothDetectResponsePayload", description = "옷 정보") + public record Payload( + @Schema(description = "새로운 옷 사진 URL", example = "https://example.com/cloth.jpg") + String clothImageUrl, + @Schema(description = "계절", example = "SPRING") Season season, + @Schema(description = "카테고리 ID", example = "1") Long categoryId, + @Schema(description = "카테고리 이름", example = "상의") String categoryName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractAiResponseDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractAiResponseDTO.java new file mode 100644 index 00000000..ea8ec77e --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractAiResponseDTO.java @@ -0,0 +1,10 @@ +package org.clokey.domain.cloth.dto.response; + +import java.util.List; +import org.clokey.cloth.enums.Season; + +public record ClothInfoExtractAiResponseDTO(List payloads) { + + public record Payload( + String clothImageUrl, Season season, Long categoryId, String categoryName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractResponse.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractResponse.java new file mode 100644 index 00000000..83611e4e --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothInfoExtractResponse.java @@ -0,0 +1,21 @@ +package org.clokey.domain.cloth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.clokey.cloth.enums.Season; + +@Schema(description = "옷 정보 추출 응답") +public record ClothInfoExtractResponse(@Schema(description = "옷 정보 목록") List payloads) { + + public static ClothInfoExtractResponse of(List payloads) { + return new ClothInfoExtractResponse(payloads); + } + + @Schema(name = "ClothInfoExtractResponsePayload", description = "옷 정보") + public record Payload( + @Schema(description = "새로운 옷 사진 URL", example = "https://example.com/cloth.jpg") + String clothImageUrl, + @Schema(description = "계절", example = "SPRING") Season season, + @Schema(description = "카테고리 ID", example = "1") Long categoryId, + @Schema(description = "카테고리 이름", example = "상의") String categoryName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceAiResponseDTO.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceAiResponseDTO.java new file mode 100644 index 00000000..3f445924 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceAiResponseDTO.java @@ -0,0 +1,9 @@ +package org.clokey.domain.cloth.dto.response; + +import java.util.List; + +public record HistoryStyleInferenceAiResponseDTO( + Long situationId, String situationName, List styles) { + + public record StylePayload(Long styleId, String styleName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceResponse.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceResponse.java new file mode 100644 index 00000000..ae7d6dca --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/HistoryStyleInferenceResponse.java @@ -0,0 +1,21 @@ +package org.clokey.domain.cloth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "기록 사진 스타일 추론 응답") +public record HistoryStyleInferenceResponse( + @Schema(description = "상황 ID", example = "1") Long situationId, + @Schema(description = "상황 이름", example = "데일리") String situationName, + @Schema(description = "스타일 목록") List styles) { + + public static HistoryStyleInferenceResponse of( + Long situationId, String situationName, List styles) { + return new HistoryStyleInferenceResponse(situationId, situationName, styles); + } + + @Schema(name = "HistoryStyleInferenceResponseStylePayload", description = "스타일 정보") + public record StylePayload( + @Schema(description = "스타일 ID", example = "1") Long styleId, + @Schema(description = "스타일 이름", example = "캐주얼") String styleName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiService.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiService.java new file mode 100644 index 00000000..a1b44303 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiService.java @@ -0,0 +1,21 @@ +package org.clokey.domain.cloth.service; + +import org.clokey.domain.cloth.dto.request.ClothDetectRequest; +import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; +import org.clokey.domain.cloth.dto.request.ClothInfoExtractRequest; +import org.clokey.domain.cloth.dto.request.HistoryStyleInferenceRequest; +import org.clokey.domain.cloth.dto.response.ClothDetectResponse; +import org.clokey.domain.cloth.dto.response.ClothImagesPresignedUrlResponse; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractResponse; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceResponse; + +public interface ClothAiService { + + ClothImagesPresignedUrlResponse getClothUploadPresignedUrls(ClothImagesUploadRequest request); + + ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request); + + HistoryStyleInferenceResponse inferHistoryStyle(HistoryStyleInferenceRequest request); + + ClothDetectResponse detectClothes(ClothDetectRequest request); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java new file mode 100644 index 00000000..84d80b93 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -0,0 +1,175 @@ +package org.clokey.domain.cloth.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.cloth.dto.request.ClothDetectAiRequestDTO; +import org.clokey.domain.cloth.dto.request.ClothDetectRequest; +import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; +import org.clokey.domain.cloth.dto.request.ClothInfoExtractAiRequestDTO; +import org.clokey.domain.cloth.dto.request.ClothInfoExtractRequest; +import org.clokey.domain.cloth.dto.request.HistoryStyleInferenceAiRequestDTO; +import org.clokey.domain.cloth.dto.request.HistoryStyleInferenceRequest; +import org.clokey.domain.cloth.dto.response.ClothDetectAiResponseDTO; +import org.clokey.domain.cloth.dto.response.ClothDetectResponse; +import org.clokey.domain.cloth.dto.response.ClothImagesPresignedUrlResponse; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractAiResponseDTO; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractResponse; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceAiResponseDTO; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceResponse; +import org.clokey.domain.cloth.exception.ClothErrorCode; +import org.clokey.domain.history.exception.HistoryErrorCode; +import org.clokey.enums.FileExtension; +import org.clokey.enums.ImageType; +import org.clokey.exception.BaseCustomException; +import org.clokey.global.util.MemberUtil; +import org.clokey.member.entity.Member; +import org.clokey.properties.WebClientProperties; +import org.clokey.util.S3Util; +import org.clokey.util.WebClientUtil; +import org.springframework.stereotype.Service; + +// FIXME: 외부 API와 연동되는 부분으로 절대로 Transaction을 붙여서 DB Connection pool을 낭비하지 말 것 ! (현재는 필요한 부분에서 +// Transaction Util을 사용하세요) +// FIXME: 현재는 Tomcat Thread Pool을 점유하고 있는 비효율적인 구조이기 때문에 나중에 비동기 처리를 통해 트래픽이 생길 경우 최적화가 필요합니다. +@Service +@RequiredArgsConstructor +public class ClothAiServiceImpl implements ClothAiService { + + private final MemberUtil memberUtil; + private final S3Util s3Util; + private final WebClientUtil webClientUtil; + private final WebClientProperties webClientProperties; + + @Override + public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( + ClothImagesUploadRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + + // 중요 : md5 해시로 변조 확인을 하기 때문에 들어온 순서대로 반환해야함!! + List presignedUrls = + request.payloads().stream() + .map( + req -> + s3Util.createPresignedUrl( + ImageType.CLOTH_IMAGE, + currentMember.getId(), + req.fileExtension(), + req.md5Hashes())) + .toList(); + + return ClothImagesPresignedUrlResponse.of(presignedUrls); + } + + @Override + public ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final List clothImageUrls = request.clothImageUrls(); + + validateImageUrls(clothImageUrls); + + // AI Server에게 N개의 사진을 전처리한 후 업로드할 수 있는 presignedUrl을 넘겨줍니다. + List presignedUrls = + createPresignedUrls(currentMember.getId(), clothImageUrls.size()); + + ClothInfoExtractAiResponseDTO aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothInferencePath(), + new ClothInfoExtractAiRequestDTO(clothImageUrls, presignedUrls), + ClothInfoExtractAiResponseDTO.class) + .block(); + + List payloads = + aiResponse.payloads().stream() + .map( + payload -> + new ClothInfoExtractResponse.Payload( + payload.clothImageUrl(), + payload.season(), + payload.categoryId(), + payload.categoryName())) + .toList(); + + return ClothInfoExtractResponse.of(payloads); + } + + @Override + public HistoryStyleInferenceResponse inferHistoryStyle(HistoryStyleInferenceRequest request) { + final String historyImageUrl = request.historyImageUrl(); + + validateImageUrl(historyImageUrl); + + HistoryStyleInferenceAiResponseDTO aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.styleInferencePath(), + new HistoryStyleInferenceAiRequestDTO(historyImageUrl), + HistoryStyleInferenceAiResponseDTO.class) + .block(); + + List styles = + aiResponse.styles().stream() + .map( + style -> + new HistoryStyleInferenceResponse.StylePayload( + style.styleId(), style.styleName())) + .toList(); + + return HistoryStyleInferenceResponse.of( + aiResponse.situationId(), aiResponse.situationName(), styles); + } + + @Override + public ClothDetectResponse detectClothes(ClothDetectRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final String imageUrl = request.imageUrl(); + + validateImageUrl(imageUrl); + + List presignedUrls = createPresignedUrls(currentMember.getId(), 10); + + ClothDetectAiResponseDTO aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothDetectPath(), + new ClothDetectAiRequestDTO(imageUrl, presignedUrls), + ClothDetectAiResponseDTO.class) + .block(); + + List payloads = + aiResponse.payloads().stream() + .map( + payload -> + new ClothDetectResponse.Payload( + payload.clothImageUrl(), + payload.season(), + payload.categoryId(), + payload.categoryName())) + .toList(); + + return ClothDetectResponse.of(payloads); + } + + private void validateImageUrls(List imageUrls) { + if (!s3Util.doAllFilesExistByUrls(imageUrls)) { + throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + } + } + + private void validateImageUrl(String imageUrl) { + if (!s3Util.doesFileExistByUrl(imageUrl)) { + throw new BaseCustomException(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND); + } + } + + // TODO : 현재 AI 서버와 비동기 처리와 더불어 양방향 통신을 고려하지 않고 있습니다. 따라서, MD5 해시를 통한 무결성 검증이 불가능하며, JPEG로 고정할 + // 것을 요청해야합니다. + private List createPresignedUrls(Long memberId, int count) { + return java.util.stream.IntStream.range(0, count) + .mapToObj( + i -> + s3Util.createPresignedUrlWithoutMd5( + ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) + .toList(); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothService.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothService.java index 9eb93514..224b4155 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothService.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothService.java @@ -3,7 +3,6 @@ import java.util.List; import org.clokey.cloth.enums.Season; import org.clokey.domain.cloth.dto.request.ClothCreateRequests; -import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; import org.clokey.domain.cloth.dto.request.ClothUpdateRequest; import org.clokey.domain.cloth.dto.response.*; import org.clokey.global.paging.SortDirection; @@ -11,8 +10,6 @@ public interface ClothService { - ClothImagesPresignedUrlResponse getClothUploadPresignedUrls(ClothImagesUploadRequest request); - ClothCreateResponse createClothes(ClothCreateRequests list); SliceResponse recommendCategoryClothes( diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java index 0b777ff0..8e9c5738 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java @@ -12,7 +12,6 @@ import org.clokey.domain.category.repository.CategoryRepository; import org.clokey.domain.cloth.dto.request.ClothCreateRequest; import org.clokey.domain.cloth.dto.request.ClothCreateRequests; -import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; import org.clokey.domain.cloth.dto.request.ClothUpdateRequest; import org.clokey.domain.cloth.dto.response.*; import org.clokey.domain.cloth.exception.ClothErrorCode; @@ -21,7 +20,6 @@ import org.clokey.domain.folder.repository.ClothFolderRepository; import org.clokey.domain.history.repository.HistoryClothTagRepository; import org.clokey.domain.image.event.ImageDeleteEvent; -import org.clokey.enums.ImageType; import org.clokey.exception.BaseCustomException; import org.clokey.global.paging.SortDirection; import org.clokey.global.util.MemberUtil; @@ -33,13 +31,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** Cloth Service에서는 외부 AI 서버와 통신을 하는 부분이 존재하며 , Transaction(DB Connection Pool)을 주의하며 사용해야 합니다. */ @Service @RequiredArgsConstructor public class ClothServiceImpl implements ClothService { private final MemberUtil memberUtil; - private final S3Util s3Util; private final ClothRepository clothRepository; private final CategoryRepository categoryRepository; @@ -48,26 +44,7 @@ public class ClothServiceImpl implements ClothService { private final ApplicationEventPublisher eventPublisher; private final CoordinateClothRepository coordinateClothRepository; - - @Override - public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( - ClothImagesUploadRequest request) { - final Member currentMember = memberUtil.getCurrentMember(); - - // 중요 : md5 해시로 변조 확인을 하기 때문에 들어온 순서대로 반환해야함!! - List presignedUrls = - request.payloads().stream() - .map( - req -> - s3Util.createPresignedUrl( - ImageType.CLOTH_IMAGE, - currentMember.getId(), - req.fileExtension(), - req.md5Hashes())) - .toList(); - - return ClothImagesPresignedUrlResponse.of(presignedUrls); - } + private final S3Util s3Util; @Override @Transactional @@ -100,6 +77,11 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { clothRepository.saveAll(clothes); + List clothImageUrls = + request.content().stream().map(ClothCreateRequest::clothImageUrl).toList(); + // 모든 선택된 url들을 확정하는 로직. + s3Util.updateTagsToCompleteByUrls(clothImageUrls); + return ClothCreateResponse.from(clothes); } diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index a1526773..5e4db4b3 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -72,6 +72,13 @@ aws: bucket: ${DEV_S3_BUCKET} endpoint: ${DEV_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +external: + api: + ai-server-ip: ${AI_SERVER_IP} + cloth-inference-path: ${CLOTH_INFERENCE_PATH} + style-inference-path: ${STYLE_INFERENCE_PATH} + cloth-detect-path: ${CLOTH_DETECT_PATH} + swagger: username: ${SWAGGER_USERNAME} password: ${SWAGGER_PASSWORD} diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index 65dd285e..add9caa8 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -72,6 +72,13 @@ aws: bucket: ${S3_BUCKET} endpoint: ${S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +external: + api: + ai-server-ip: ${AI_SERVER_IP} + cloth-inference-path: ${CLOTH_INFERENCE_PATH} + style-inference-path: ${STYLE_INFERENCE_PATH} + cloth-detect-path: ${CLOTH_DETECT_PATH} + spring-doc: default-consumes-media-type: application/json default-produces-media-type: application/json diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index 0fb232a0..7731e62a 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -75,3 +75,10 @@ aws: firebase: credentials-path: ${FIREBASE_CREDENTIALS_PATH} + +external: + api: + ai-server-ip: ${AI_SERVER_IP} + cloth-inference-path: ${CLOTH_INFERENCE_PATH} + style-inference-path: ${STYLE_INFERENCE_PATH} + cloth-detect-path: ${CLOTH_DETECT_PATH} diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java index 4327f09a..c924bb36 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java @@ -14,6 +14,7 @@ import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest; import org.clokey.domain.cloth.dto.request.ClothUpdateRequest; import org.clokey.domain.cloth.dto.response.*; +import org.clokey.domain.cloth.service.ClothAiService; import org.clokey.domain.cloth.service.ClothService; import org.clokey.enums.FileExtension; import org.clokey.global.paging.SortDirection; @@ -40,6 +41,7 @@ class ClothControllerTest { @Autowired private ObjectMapper objectMapper; @MockitoBean private ClothService clothService; + @MockitoBean private ClothAiService clothAiService; @Nested class 옷_업로드_presigned_url_발급_요청_시 { @@ -59,7 +61,7 @@ class 옷_업로드_presigned_url_발급_요청_시 { new ClothImagesPresignedUrlResponse( List.of("testPresignedUrl1", "testPresignedUrl2")); - given(clothService.getClothUploadPresignedUrls(request)).willReturn(response); + given(clothAiService.getClothUploadPresignedUrls(request)).willReturn(response); // when & then ResultActions perform = diff --git a/clokey-infrastructure/build.gradle b/clokey-infrastructure/build.gradle index bc3b7039..03f030f1 100644 --- a/clokey-infrastructure/build.gradle +++ b/clokey-infrastructure/build.gradle @@ -16,4 +16,5 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' api 'com.google.firebase:firebase-admin:9.7.0' + implementation 'org.springframework.boot:spring-boot-starter-webflux' } diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/WebClientConfig.java b/clokey-infrastructure/src/main/java/org/clokey/config/WebClientConfig.java new file mode 100644 index 00000000..533a1afc --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/config/WebClientConfig.java @@ -0,0 +1,34 @@ +package org.clokey.config; + +import io.netty.channel.ChannelOption; +import java.time.Duration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + HttpClient httpClient = + HttpClient.create() + .responseTimeout(Duration.ofSeconds(30)) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs( + configurer -> + configurer + .defaultCodecs() + .maxInMemorySize(10 * 1024 * 1024)); // 10MB + } + + @Bean + public WebClient webClient(WebClient.Builder webClientBuilder) { + return webClientBuilder.build(); + } +} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java index bf10f7ad..438027e4 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java @@ -8,6 +8,7 @@ RedisProperties.class, JwtProperties.class, S3Properties.class, - AwsProperties.class + AwsProperties.class, + WebClientProperties.class }) public class PropertiesConfig {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/WebClientProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/WebClientProperties.java new file mode 100644 index 00000000..137563e7 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/WebClientProperties.java @@ -0,0 +1,10 @@ +package org.clokey.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("external.api") +public record WebClientProperties( + String aiServerIp, + String clothInferencePath, + String styleInferencePath, + String clothDetectPath) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java b/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java index f588a1b6..c9fba533 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java +++ b/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java @@ -37,6 +37,19 @@ public String createPresignedUrl( return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); } + public String createPresignedUrlWithoutMd5( + ImageType imageType, Long memberId, FileExtension fileExtension) { + String imageKey = UUID.randomUUID().toString(); + String fileName = createFileName(imageType, memberId, imageKey, fileExtension); + String bucket = s3Properties.bucket(); + + GeneratePresignedUrlRequest generatePresignedUrlRequest = + generatePresignedUrlRequestWithoutMd5( + bucket, fileName, fileExtension.getExtension()); + + return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + } + private String createFileName( ImageType imageType, Long memberId, String imageKey, FileExtension fileExtension) { return memberId @@ -66,6 +79,22 @@ private GeneratePresignedUrlRequest generatePresignedUrlRequest( return generatePresignedUrlRequest; } + private GeneratePresignedUrlRequest generatePresignedUrlRequestWithoutMd5( + String bucket, String fileName, String imageFileExtension) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) + .withKey(fileName) + .withContentType("image/" + imageFileExtension) + .withExpiration(getPresignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString()); + + generatePresignedUrlRequest.addRequestParameter("x-amz-tagging", "status=pending"); + + return generatePresignedUrlRequest; + } + public void deleteAllByUrls(List urls) { if (urls == null || urls.isEmpty()) { log.info("deleteAllByUrls skipped: received null or empty urls"); diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java new file mode 100644 index 00000000..95382964 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java @@ -0,0 +1,30 @@ +package org.clokey.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.clokey.properties.WebClientProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WebClientUtil { + + private final WebClient.Builder webClientBuilder; + private final WebClientProperties webClientProperties; + + public Mono postToAiServer(String path, T requestBody, Class responseType) { + WebClient webClient = + webClientBuilder.baseUrl("http://" + webClientProperties.aiServerIp()).build(); + + return webClient + .post() + .uri(path) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(responseType) + .doOnError(error -> log.error("AI 서버 요청 실패: {}", error.getMessage(), error)); + } +}