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
13 changes: 13 additions & 0 deletions src/docs/asciidoc/interaction/style/style.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,16 @@ include::{snippets}/style-analysis/request-parts.adoc[]

include::{snippets}/style-analysis/http-response.adoc[]
include::{snippets}/style-analysis/response-fields.adoc[]

[[style-virtual-try-on]]
=== 스타일 가상 체험

==== HTTP Request

include::{snippets}/style-virtual-try-on/http-request.adoc[]
include::{snippets}/style-virtual-try-on/request-fields.adoc[]

==== HTTP Response

include::{snippets}/style-virtual-try-on/http-response.adoc[]
include::{snippets}/style-virtual-try-on/response-fields.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,13 @@ public ResponseEntity<Object> bindException(BindException e) {
public ResponseEntity<Object> methodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error(e.getMessage(), e);

String errorMessage = e.getBindingResult()
.getAllErrors()
.get(0)
.getDefaultMessage();

return ResponseEntity.status(BAD_REQUEST)
.body(new ExceptionResult(BAD_REQUEST.name(), e.getMessage()));
.body(new ExceptionResult(BAD_REQUEST.name(), errorMessage));
}

/** Handles JWT authentication errors (JwtTokenException). */
Expand Down
48 changes: 48 additions & 0 deletions src/main/java/com/seomse/common/util/ByteArrayMultipartFile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.seomse.common.util;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

import org.springframework.web.multipart.MultipartFile;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class ByteArrayMultipartFile implements MultipartFile {
private final byte[] content;
private final String name;
private final String originalFilename;
private final String contentType;

@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}

@Override
public long getSize() {
return content.length;
}

@Override
public byte[] getBytes() throws IOException {
return content;
}

@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}

@Override
public void transferTo(File dest) throws IOException, IllegalStateException {
try (FileOutputStream fos = new FileOutputStream(dest)) {
fos.write(content);
}
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/seomse/interaction/style/client/AiApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import com.seomse.interaction.style.service.dto.AiVirtualTryOnRequest;
import com.seomse.interaction.style.service.dto.StyleAnalysisRequest;
import com.seomse.interaction.style.service.dto.StyleAnalysisResponse;

Expand All @@ -25,4 +26,15 @@ public StyleAnalysisResponse analyzeStyle(StyleAnalysisRequest requestBody) {
.bodyToMono(StyleAnalysisResponse.class)
.block();
}

public byte[] virtualTryOn(AiVirtualTryOnRequest requestBody) {
return webClient.post()
.uri("/virtual-try-on")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.IMAGE_PNG)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(byte[].class)
.block();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.seomse.common.controller.ApiResponse;
import com.seomse.interaction.style.controller.request.VirtualTryOnRequest;
import com.seomse.interaction.style.service.StyleService;
import com.seomse.interaction.style.service.dto.StyleAnalysisResponse;
import com.seomse.interaction.style.service.dto.VirtualTryOnResponse;

import lombok.RequiredArgsConstructor;

Expand All @@ -28,4 +31,10 @@ public class StyleController {
public ApiResponse<StyleAnalysisResponse> analyzeStyle(@RequestPart MultipartFile image) throws IOException {
return ApiResponse.created(styleService.callAnalyzeStyle(image));
}

@PostMapping("/virtual-try-on")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<VirtualTryOnResponse> virtualTryOn(@RequestBody VirtualTryOnRequest request) throws IOException {
return ApiResponse.created(styleService.callVirtualTryOn(request.toServiceRequest()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.seomse.interaction.style.controller.request;

import com.seomse.interaction.style.service.request.VirtualTryOnServiceRequest;

import jakarta.validation.constraints.NotBlank;

public record VirtualTryOnRequest(
@NotBlank(message = "imageUrl is required.")
String imageUrl,

@NotBlank(message = "targetHairstyle is required.")
String targetHairstyle,

@NotBlank(message = "targetHairColor is required.")
String targetHairColor

) {
public VirtualTryOnServiceRequest toServiceRequest() {
return new VirtualTryOnServiceRequest(imageUrl, targetHairstyle, targetHairColor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ public class StyleAnalysisEntity extends BaseTimeEntity {
@Column(nullable = false)
private StyleAnalysisResponse result;

@Column(length = 190)
private String virtualTryOnImage;

public StyleAnalysisEntity(ClientEntity client, String image, StyleAnalysisResponse result) {
this.client = client;
this.image = image;
this.result = result;
}

public void updateVirtualTryOnImage(String virtualTryOnImageUrl) {
this.virtualTryOnImage = virtualTryOnImageUrl;
}
}

Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.seomse.interaction.style.repository;

import java.util.Optional;
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;

import com.seomse.interaction.style.entity.StyleAnalysisEntity;

public interface StyleAnalysisRepository extends JpaRepository<StyleAnalysisEntity, UUID> {

Optional<StyleAnalysisEntity> findByImage(String image);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.seomse.interaction.style.service;

import java.io.IOException;
import java.util.UUID;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import com.seomse.common.util.ByteArrayMultipartFile;
import com.seomse.interaction.style.client.AiApiClient;
import com.seomse.interaction.style.entity.StyleAnalysisEntity;
import com.seomse.interaction.style.repository.StyleAnalysisRepository;
import com.seomse.interaction.style.service.dto.AiVirtualTryOnRequest;
import com.seomse.interaction.style.service.dto.StyleAnalysisRequest;
import com.seomse.interaction.style.service.dto.StyleAnalysisResponse;
import com.seomse.interaction.style.service.dto.VirtualTryOnResponse;
import com.seomse.interaction.style.service.request.VirtualTryOnServiceRequest;
import com.seomse.s3.service.S3Service;
import com.seomse.security.jwt.dto.LoginUserInfo;
import com.seomse.security.service.SecurityService;
Expand Down Expand Up @@ -39,7 +44,7 @@ public StyleAnalysisResponse callAnalyzeStyle(MultipartFile image) throws IOExce
.orElseThrow(() -> new IllegalArgumentException("User not found."));

final String S3_FOLDER = "style";
final String s3Key = (image != null && !image.isEmpty()) ? s3Service.upload(image, S3_FOLDER) : null;
final String s3Key = s3Service.upload(image, S3_FOLDER);

StyleAnalysisRequest requestBody = new StyleAnalysisRequest(s3Key, client.getGender().toString(),
client.getAge().toString());
Expand All @@ -51,4 +56,33 @@ public StyleAnalysisResponse callAnalyzeStyle(MultipartFile image) throws IOExce

return response;
}

public VirtualTryOnResponse callVirtualTryOn(VirtualTryOnServiceRequest request) throws IOException {

StyleAnalysisEntity analysisEntity = styleAnalysisRepository.findByImage(request.imageUrl())
.orElseThrow(() -> new IllegalArgumentException("image not found"));

LoginUserInfo loginUser = securityService.getCurrentLoginUserInfo();
ClientEntity client = clientRepository.findById(loginUser.userId())
.orElseThrow(() -> new IllegalArgumentException("User not found."));

AiVirtualTryOnRequest aiRequest = new AiVirtualTryOnRequest(request.imageUrl(), request.targetHairstyle(),
request.targetHairColor(), client.getGender());

byte[] generatedImageBytes = aiApiClient.virtualTryOn(aiRequest);

MultipartFile generatedFile = new ByteArrayMultipartFile(
generatedImageBytes,
"file",
UUID.randomUUID() + ".png",
"image/png"
);

final String S3_FOLDER = "style";
final String resultImageUrl = s3Service.upload(generatedFile, S3_FOLDER);

analysisEntity.updateVirtualTryOnImage(resultImageUrl);

return new VirtualTryOnResponse(resultImageUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.seomse.interaction.style.service.dto;

import com.seomse.user.client.enums.Gender;

public record AiVirtualTryOnRequest(String imageUrl, String targetHairstyle, String targetHairColor, Gender gender) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.seomse.interaction.style.service.dto;

public record VirtualTryOnResponse(String generatedImageUrl) {
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.seomse.interaction.style.service.request;

public record VirtualTryOnServiceRequest(String imageUrl, String targetHairstyle, String targetHairColor) {
}
17 changes: 6 additions & 11 deletions src/test/java/com/seomse/docs/RestDocsSupport.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.seomse.docs;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
Expand All @@ -16,6 +15,7 @@
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

@ExtendWith(RestDocumentationExtension.class)
@AutoConfigureRestDocs(uriHost = "api.seomse.kro.kr")
public abstract class RestDocsSupport {

protected MockMvc mockMvc;
Expand All @@ -31,15 +31,10 @@ public RestDocsSupport() {
void setup(RestDocumentationContextProvider provider) {
this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
.setMessageConverters(new MappingJackson2HttpMessageConverter(this.objectMapper))
.apply(MockMvcRestDocumentation.documentationConfiguration(provider)
.operationPreprocessors()
.withRequestDefaults(modifyUris()
.scheme("https")
.host("api.seomse.kro.kr")
.removePort(),
prettyPrint())
.withResponseDefaults(prettyPrint())
)
.apply(MockMvcRestDocumentation.documentationConfiguration(provider).uris()
.withScheme("https")
.withHost("api.seomse.kro.kr")
.withPort(443))
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@

import com.seomse.docs.RestDocsSupport;
import com.seomse.interaction.style.controller.StyleController;
import com.seomse.interaction.style.controller.request.VirtualTryOnRequest;
import com.seomse.interaction.style.service.StyleService;
import com.seomse.interaction.style.service.dto.StyleAnalysisResponse;
import com.seomse.interaction.style.service.dto.VirtualTryOnResponse;
import com.seomse.interaction.style.service.request.VirtualTryOnServiceRequest;

public class StyleControllerDocsTest extends RestDocsSupport {

Expand Down Expand Up @@ -94,4 +97,52 @@ void analyzeStyle() throws Exception {
)
));
}

@DisplayName("가상 헤어스타일 체험 API")
@Test
void virtualTryOn() throws Exception {
// given
VirtualTryOnRequest request = new VirtualTryOnRequest(
"https://test.cloudfront.net/style/test.png",
"댄디컷",
"애쉬 브라운"
);

VirtualTryOnResponse response = new VirtualTryOnResponse(
"https://test.cloudfront.net/style/generated_result.png"
);

given(styleService.callVirtualTryOn(any(VirtualTryOnServiceRequest.class)))
.willReturn(response);

// when // then
mockMvc.perform(
post("/interaction/styles/virtual-try-on")
.header(HttpHeaders.AUTHORIZATION, "Bearer <JWT ACCESS TOKEN>")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isCreated())
.andDo(print())
.andDo(document("style-virtual-try-on",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),

requestFields(
fieldWithPath("imageUrl").type(JsonFieldType.STRING)
.description("원본 이미지 URL"),
fieldWithPath("targetHairstyle").type(JsonFieldType.STRING)
.description("적용할 타겟 헤어스타일 이름 (예: 댄디컷, 리젠트컷)"),
fieldWithPath("targetHairColor").type(JsonFieldType.STRING)
.description("적용할 타겟 헤어 컬러 (예: 애쉬 브라운, 블랙)")
),

responseFields(
fieldWithPath("statusCode").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("data").type(JsonFieldType.OBJECT).description("가상 체험 결과 데이터"),
fieldWithPath("data.generatedImageUrl").type(JsonFieldType.STRING)
.description("생성된 가상 체험 결과 이미지 URL")
)
));
}
}
Loading