diff --git a/build.gradle b/build.gradle index 91607be..ffab229 100644 --- a/build.gradle +++ b/build.gradle @@ -4,10 +4,6 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -ext { - springAiVersion = "1.1.2" -} - group = 'com.example' version = '0.0.1-SNAPSHOT' description = 'team4-backend' @@ -18,47 +14,48 @@ java { } } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} - repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:1.1.2" + } +} + dependencies { + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // Spring Boot implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web-services' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // OpenAPI implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + // DB runtimeOnly 'com.mysql:mysql-connector-j' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.ws:spring-ws-test' - - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'org.springframework.boot:spring-boot-starter-security' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - implementation 'org.springframework.boot:spring-boot-starter-webflux' - // models - implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' + // Spring AI implementation 'org.springframework.ai:spring-ai-starter-model-openai' -} + implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' -dependencyManagement { - imports { - mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" - } + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.ws:spring-ws-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { diff --git a/src/main/java/com/example/team4backend/controller/AiController.java b/src/main/java/com/example/team4backend/controller/AiController.java index d07c90e..af4de81 100644 --- a/src/main/java/com/example/team4backend/controller/AiController.java +++ b/src/main/java/com/example/team4backend/controller/AiController.java @@ -3,12 +3,19 @@ import com.example.team4backend.common.response.ApiResult; import com.example.team4backend.dto.PromptRequest; import com.example.team4backend.service.AiService; +import com.example.team4backend.service.ImageInput; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; @Tag(name = "Ai", description = "LLM 호출 API") @RestController @@ -19,10 +26,36 @@ public class AiController { @PostMapping("/prompts") public ResponseEntity> chat(@RequestBody PromptRequest promptRequest) { - System.out.println("=== AiController.chat() 호출됨 === userInput: " + promptRequest.getUserInput()); String aiResponse = aiService.generateContent(promptRequest.getUserInput()); - System.out.println("=== AiController.chat() 응답 완료 ==="); return ResponseEntity.ok(ApiResult.ok(aiResponse)); } + + @PostMapping(value = "/prompts/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> chatWithImages( + @RequestPart("images") List images) { + + if (images == null || images.isEmpty()) { + throw new IllegalArgumentException("최소 1개 이상의 이미지가 필요합니다."); + } + if (images.size() > 3) { + throw new IllegalArgumentException("이미지는 최대 3개까지 업로드할 수 있습니다."); + } + + List imageInputs = images.stream() + .map(file -> { + try { + String mimeType = file.getContentType() != null + ? file.getContentType() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + return new ImageInput(file.getBytes(), mimeType); + } catch (IOException e) { + throw new RuntimeException("이미지 파일을 읽는 중 오류가 발생했습니다.", e); + } + }) + .toList(); + + String aiResponse = aiService.generateContentFromImages(imageInputs); + return ResponseEntity.ok(ApiResult.ok(aiResponse)); + } } diff --git a/src/main/java/com/example/team4backend/domain/TargetPerson.java b/src/main/java/com/example/team4backend/domain/TargetPerson.java index 5967acf..a6293ab 100644 --- a/src/main/java/com/example/team4backend/domain/TargetPerson.java +++ b/src/main/java/com/example/team4backend/domain/TargetPerson.java @@ -52,10 +52,14 @@ public class TargetPerson extends BaseTimeEntity { @Column(columnDefinition = "TEXT") private String recommendedOpening; + @Column(columnDefinition = "LONGTEXT") + private String chatContent; + private Instant deletedAt; @Builder - public TargetPerson(User user, String name, Relationship relationship, ChatStyle chatStyle, Integer age, String phoneNumber, LocalDate birthday, String interests) { + public TargetPerson(User user, String name, Relationship relationship, ChatStyle chatStyle, String chatContent, + Integer age, String phoneNumber, LocalDate birthday, String interests) { this.user = user; this.name = name; this.age = age; @@ -64,6 +68,7 @@ public TargetPerson(User user, String name, Relationship relationship, ChatStyle this.interests = interests; this.relationship = relationship; this.chatStyle = chatStyle; + this.chatContent = chatContent; this.deletedAt = null; } diff --git a/src/main/java/com/example/team4backend/dto/MessageListResponse.java b/src/main/java/com/example/team4backend/dto/MessageListResponse.java index 24b7dce..4cae3e6 100644 --- a/src/main/java/com/example/team4backend/dto/MessageListResponse.java +++ b/src/main/java/com/example/team4backend/dto/MessageListResponse.java @@ -14,6 +14,9 @@ public record MessageListResponse( @Schema(description = "대상자 이름", example = "홍길동") String name, + @Schema(description = "전화번호", example = "01029050166") + String phoneNumber, + @Schema(description = "추천 서두", example = "안녕하세요! 오랜만이에요") String recommendedOpening, @@ -25,6 +28,7 @@ public static MessageListResponse of(TargetPerson target, String recommendedOpen return new MessageListResponse( target.getId(), target.getName(), + target.getPhoneNumber(), recommendedOpening, target.getLastMessageDate() ); diff --git a/src/main/java/com/example/team4backend/dto/TargetListResponse.java b/src/main/java/com/example/team4backend/dto/TargetListResponse.java index 71a6510..4a6d659 100644 --- a/src/main/java/com/example/team4backend/dto/TargetListResponse.java +++ b/src/main/java/com/example/team4backend/dto/TargetListResponse.java @@ -5,6 +5,9 @@ public record TargetListResponse( + @Schema(description = "대상자 ID", example = "1") + Long targetId, + @Schema(description = "이름", example = "홍길동") String name, @@ -13,6 +16,7 @@ public record TargetListResponse( ){ public static TargetListResponse from(TargetPerson target) { return new TargetListResponse( + target.getId(), target.getName(), target.getRelationship().getDescription() ); diff --git a/src/main/java/com/example/team4backend/dto/TargetRequest.java b/src/main/java/com/example/team4backend/dto/TargetRequest.java index 60b0aad..61af541 100644 --- a/src/main/java/com/example/team4backend/dto/TargetRequest.java +++ b/src/main/java/com/example/team4backend/dto/TargetRequest.java @@ -21,6 +21,8 @@ public record TargetRequest( @NotNull(message = "채팅 스타일은 필수입니다.") Long chatStyleId, + String chatContent, + @Schema(description = "나이", example = "25") Integer age, diff --git a/src/main/java/com/example/team4backend/service/AiService.java b/src/main/java/com/example/team4backend/service/AiService.java index 588b032..c3a68ee 100644 --- a/src/main/java/com/example/team4backend/service/AiService.java +++ b/src/main/java/com/example/team4backend/service/AiService.java @@ -1,6 +1,9 @@ package com.example.team4backend.service; +import java.util.List; + public interface AiService { String generateContent(String prompt); Object getOptions(); + String generateContentFromImages(List images); } diff --git a/src/main/java/com/example/team4backend/service/GeminiService.java b/src/main/java/com/example/team4backend/service/GeminiService.java index ee8aad4..226d4d2 100644 --- a/src/main/java/com/example/team4backend/service/GeminiService.java +++ b/src/main/java/com/example/team4backend/service/GeminiService.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.List; + @Qualifier @Service public class GeminiService implements AiService { @@ -51,4 +53,9 @@ public String generateContent(String prompt) { public Object getOptions() { return null; } + + @Override + public String generateContentFromImages(List images) { + return ""; + } } diff --git a/src/main/java/com/example/team4backend/service/ImageInput.java b/src/main/java/com/example/team4backend/service/ImageInput.java new file mode 100644 index 0000000..2755b44 --- /dev/null +++ b/src/main/java/com/example/team4backend/service/ImageInput.java @@ -0,0 +1,4 @@ +package com.example.team4backend.service; + +public record ImageInput(byte[] data, String mimeType) { +} diff --git a/src/main/java/com/example/team4backend/service/KakaoOAuthService.java b/src/main/java/com/example/team4backend/service/KakaoOAuthService.java index 5aaaa8f..f07953a 100644 --- a/src/main/java/com/example/team4backend/service/KakaoOAuthService.java +++ b/src/main/java/com/example/team4backend/service/KakaoOAuthService.java @@ -24,9 +24,6 @@ public class KakaoOAuthService { @Value("${oauth2.kakao.client-id}") private String clientId; - @Value("${oauth2.kakao.client-secret:}") - private String clientSecret; - @Value("${oauth2.kakao.redirect-uri}") private String redirectUri; @@ -43,9 +40,6 @@ public KakaoTokenResponse getToken(String code) { body.add("client_id", clientId); body.add("redirect_uri", redirectUri); body.add("code", code); - if (clientSecret != null && !clientSecret.isBlank()) { - body.add("client_secret", clientSecret); - } try { return webClient.post() diff --git a/src/main/java/com/example/team4backend/service/OpenAiService.java b/src/main/java/com/example/team4backend/service/OpenAiService.java index 8d40226..54363d9 100644 --- a/src/main/java/com/example/team4backend/service/OpenAiService.java +++ b/src/main/java/com/example/team4backend/service/OpenAiService.java @@ -1,12 +1,25 @@ package com.example.team4backend.service; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.content.Media; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.MimeType; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + + import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.chat.prompt.Prompt; +import javax.imageio.ImageIO; @Primary @Service @@ -23,16 +36,89 @@ public OpenAiService(OpenAiChatModel chatModel) { @Override public String generateContent(String prompt) { + var options = (OpenAiChatOptions) getOptions(); + return chatModel.call(new Prompt(prompt, options)) + .getResult().getOutput().getText(); + } + + + @Override + public String generateContentFromImages(List images) { + + String fixedPrompt = """ + You are an assistant that extracts text from images. + + If the image is a chat or messaging conversation: + - Extract ONLY the messages sent by the current user. + - The current user's messages are on the RIGHT side. + - NEVER extract any LEFT-side messages. + - Remove timestamps, names, profile info, system notices, and reactions. + - Output ONLY the extracted message text in Korean. + """; var options = (OpenAiChatOptions) getOptions(); - return chatModel.call(new Prompt(prompt, options)).getResult() - .getOutput().getText(); + List mediaList = images.stream() + .map(image -> { + CroppedImage cropped = cropRightOnly(image.data(), image.mimeType(), 0.70); + + return new Media( + MimeType.valueOf(cropped.mimeType()), + new ByteArrayResource(cropped.bytes()) + ); + }) + .toList(); + + UserMessage userMessage = UserMessage.builder() + .text(fixedPrompt) + .media(mediaList) + .build(); + + return chatModel + .call(new Prompt(List.of(userMessage), options)) + .getResult() + .getOutput() + .getText(); } - public Object getOptions() { - // TODO: relationship table에서 weight 출력 + private CroppedImage cropRightOnly(byte[] originalBytes, String originalMimeType, double keepRatio) { + try { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(originalBytes)); + if (img == null) return new CroppedImage(originalBytes, originalMimeType); + + int w = img.getWidth(); + int h = img.getHeight(); + + int keepW = Math.max(1, (int) Math.round(w * keepRatio)); + int x = Math.max(0, w - keepW); + + BufferedImage rightOnly = img.getSubimage(x, 0, keepW, h); + + String format = mimeToFormat(originalMimeType); + String outMime = format.equals("png") ? "image/png" : "image/jpeg"; + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(rightOnly, format, baos); + return new CroppedImage(baos.toByteArray(), outMime); + } catch (Exception e) { + return new CroppedImage(originalBytes, originalMimeType); + } + } + + private String mimeToFormat(String mime) { + if (mime == null) return "png"; + String m = mime.toLowerCase(); + if (m.contains("png")) return "png"; + if (m.contains("jpeg") || m.contains("jpg")) return "jpg"; + return "png"; + } + + private record CroppedImage(byte[] bytes, String mimeType) {} + + + + public Object getOptions() { return OpenAiChatOptions.builder() .model(modelName) .temperature(0.5) diff --git a/src/main/java/com/example/team4backend/service/TargetService.java b/src/main/java/com/example/team4backend/service/TargetService.java index c577cca..1f8cfda 100644 --- a/src/main/java/com/example/team4backend/service/TargetService.java +++ b/src/main/java/com/example/team4backend/service/TargetService.java @@ -48,6 +48,7 @@ public Long addTarget(Long userId, TargetRequest dto) { .name(dto.name()) .relationship(relationship) .chatStyle(chatStyle) + .chatContent(dto.chatContent()) .age(dto.age()) .phoneNumber(dto.phoneNumber()) .birthday(dto.birthday())