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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ RUN gradle clean build -x test --no-daemon
FROM eclipse-temurin:21-jdk
WORKDIR /app

# curl for actuator healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

# Copy the built JAR from build stage
COPY --from=build /app/build/libs/*.jar app.jar

Expand Down
18 changes: 18 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// JSON 로깅 (ELK/모니터링 연동 대비)
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
// SpringDoc OpenAPI (Swagger)
// Spring Boot 4.0.x / Spring Framework 6.2+ 호환을 위한 최신 버전
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0'
Expand All @@ -52,6 +55,21 @@ dependencies {

}

tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}

tasks.named('test') {
useJUnitPlatform()
jvmArgs = ['-Dfile.encoding=UTF-8', '-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8']
}

tasks.named('bootRun') {
jvmArgs = [
'-Dfile.encoding=UTF-8',
'-Dstdout.encoding=UTF-8',
'-Dstderr.encoding=UTF-8',
'-Dsun.stdout.encoding=UTF-8',
'-Dsun.stderr.encoding=UTF-8'
]
}
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,15 @@ services:
restart: always
ports:
- "${SERVER_PORT}:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
environment:
SERVER_PORT: 8080
SPRING_PROFILES_ACTIVE: docker
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
Expand Down
3 changes: 2 additions & 1 deletion gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;

@EnableAsync
@SpringBootApplication
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.example.Capstone_project.repository")
public class CapstoneProjectApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,43 @@
package com.example.Capstone_project.common.exception;

import com.example.Capstone_project.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<?>> handleResourceNotFoundException(ResourceNotFoundException e) {
log.warn("Resource not found: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(e.getMessage()));
}

@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ApiResponse<?>> handleBadRequestException(BadRequestException e) {
log.warn("Bad request: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(e.getMessage()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<?>> handleIllegalArgument(IllegalArgumentException e) {
log.warn("Illegal argument: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(e.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
log.error("Unhandled exception: {} - {}", e.getClass().getSimpleName(), e.getMessage(), e);
String clientMessage = "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Internal server error: " + e.getMessage()));
.body(ApiResponse.error(clientMessage));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.example.Capstone_project.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
Expand Down Expand Up @@ -37,6 +36,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Actuator 헬스체크 (Docker/로드밸런서용)
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Swagger 및 API 문서
.requestMatchers("/v3/api-docs/**", "/v3/api-docs").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.Capstone_project.controller;

import com.example.Capstone_project.common.dto.ApiResponse;
import com.example.Capstone_project.config.CustomUserDetails;
import com.example.Capstone_project.dto.ChatRequestDto;
import com.example.Capstone_project.dto.ChatResponseDto;
import com.example.Capstone_project.service.ChatService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;

@Tag(name = "Chat", description = "Gemini AI 챗봇. 대화 중 스타일 추천 요청 시 Gemini function calling(style_recommend 등)으로 추천 후 답변 생성.")
@RestController
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ChatController {

private final ChatService chatService;

@Operation(
summary = "챗봇 메시지 전송",
description = "사용자 메시지를 보내면 AI가 응답합니다. '결혼식 옷 추천해줘', '캐주얼 스타일 추천' 등 요청 시 스타일 추천 툴이 호출되어 추천 결과를 반환합니다."
)
@PostMapping
public ResponseEntity<ApiResponse<ChatResponseDto>> chat(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody ChatRequestDto request
) {
Long userId = userDetails.getUser().getId();
ChatResponseDto response = chatService.chat(userId, request);
return ResponseEntity.ok(ApiResponse.success("챗봇 응답", response));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package com.example.Capstone_project.controller;

import com.example.Capstone_project.common.dto.ApiResponse;
import com.example.Capstone_project.common.exception.BadRequestException;
import com.example.Capstone_project.common.exception.ResourceNotFoundException;
import com.example.Capstone_project.domain.Clothes;
import com.example.Capstone_project.domain.ClothesUploadStatus;
import com.example.Capstone_project.domain.ClothesUploadTask;
import com.example.Capstone_project.domain.User;
import com.example.Capstone_project.dto.ClothesRequestDto;
import com.example.Capstone_project.dto.ClothesResponseDto;
import com.example.Capstone_project.dto.ClothesUploadStatusResponse;
import com.example.Capstone_project.dto.ClothesUploadTaskIdResponse;
import com.example.Capstone_project.repository.ClothesRepository;
import com.example.Capstone_project.repository.ClothesUploadTaskRepository;
import com.example.Capstone_project.service.ClothesAnalysisService;
import com.example.Capstone_project.service.ClothesUploadSseService;
import com.example.Capstone_project.service.GoogleCloudStorageService;
import com.example.Capstone_project.service.RedisLockService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.Capstone_project.config.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -21,6 +29,7 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.List;
Expand All @@ -34,16 +43,20 @@
public class ClothesController {

private final ClothesRepository clothesRepository;
private final ClothesUploadTaskRepository clothesUploadTaskRepository;
private final ClothesAnalysisService clothesAnalysisService;
private final ClothesUploadSseService clothesUploadSseService;
private final GoogleCloudStorageService gcsService;
private final RedisLockService redisLockService;
private final ObjectMapper objectMapper;

@Operation(
summary = "옷 등록",
description = "옷 사진 1장을 업로드하여 AI 분석 후 저장합니다. **비동기 처리** → 즉시 202 Accepted 반환, 백그라운드에서 분석·저장됩니다."
description = "옷 사진 1장을 업로드하여 AI 분석 후 저장합니다. **비동기 + SSE** → 202 Accepted와 taskId 반환. " +
"GET /api/v1/clothes/upload/{taskId}/stream 으로 진행 상황(이벤트 name=status)을 실시간 수신하세요."
)
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<String>> uploadClothes(
public ResponseEntity<ApiResponse<ClothesUploadTaskIdResponse>> uploadClothes(
@Parameter(description = "옷 이미지 파일", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "카테고리 (Top / Bottom / Shoes)", example = "Top", required = true) @RequestParam("category") String category,
@AuthenticationPrincipal CustomUserDetails userDetails
Expand All @@ -59,7 +72,6 @@ public ResponseEntity<ApiResponse<String>> uploadClothes(
final Long userId = userDetails.getUser().getId();
final String lockKey = "lock:clothes-upload:" + userId;

// 1) 락 시도
if (!redisLockService.tryLock(lockKey, Duration.ofSeconds(8))) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("이미 옷 등록이 처리 중입니다. 잠시 후 다시 시도해주세요."));
Expand All @@ -68,17 +80,66 @@ public ResponseEntity<ApiResponse<String>> uploadClothes(
try {
byte[] imageBytes = file.getBytes();
String filename = file.getOriginalFilename();
clothesAnalysisService.analyzeAndSaveClothesAsync(imageBytes, filename, category, userDetails.getUser());

ClothesUploadTask task = ClothesUploadTask.builder()
.userId(userId)
.category(category)
.status(ClothesUploadStatus.WAITING)
.build();
task = clothesUploadTaskRepository.save(task);
Long taskId = task.getId();

clothesAnalysisService.startClothesUploadAndNotify(taskId, imageBytes, filename, category, userDetails.getUser());

ClothesUploadTaskIdResponse body = new ClothesUploadTaskIdResponse(taskId);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(ApiResponse.success(
"옷 등록이 시작되었습니다. GET /api/v1/clothes/upload/" + taskId + "/stream 으로 진행 상황을 확인하세요.",
body));
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("파일 읽기 실패: " + e.getMessage()));
}
}

@Operation(
summary = "옷 업로드 진행 상황 스트림 (SSE)",
description = "taskId에 대한 상태 변경을 실시간으로 수신합니다. 이벤트 name=status (가상 피팅과 동일). " +
"이미 COMPLETED/FAILED면 현재 상태 1회 전송 후 종료."
)
@GetMapping(value = "/upload/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamClothesUploadStatus(
@Parameter(description = "업로드 작업 ID", required = true) @PathVariable Long taskId,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
Long userId = userDetails.getUser().getId();
ClothesUploadTask task = clothesUploadTaskRepository.findById(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Clothes upload task not found: " + taskId));
if (!userId.equals(task.getUserId())) {
throw new BadRequestException("해당 작업에 대한 권한이 없습니다.");
}

// -> “연타 방지” 목적이면 TTL로 자연 해제시키는 게 안전합니다.
ClothesUploadStatusResponse current = ClothesUploadStatusResponse.builder()
.taskId(task.getId())
.status(task.getStatus())
.clothesId(task.getClothesId())
.errorMessage(task.getErrorMessage())
.build();

if (task.getStatus() == ClothesUploadStatus.COMPLETED || task.getStatus() == ClothesUploadStatus.FAILED) {
SseEmitter emitter = new SseEmitter(60_000L);
clothesUploadSseService.sendOnceAndComplete(emitter, current);
return emitter;
}

return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(ApiResponse.success("Clothes registration started. Processing in background.",
"옷 등록이 시작되었습니다. 백그라운드에서 분석 및 저장이 진행됩니다."));
SseEmitter registered = clothesUploadSseService.register(taskId);
try {
registered.send(SseEmitter.event().name("status").data(objectMapper.writeValueAsString(current)));
} catch (IOException e) {
log.warn("SSE initial send failed for clothes upload taskId={}", taskId, e);
clothesUploadSseService.sendOnceAndComplete(registered, current);
}
return registered;
}

// @Operation(
Expand Down
Loading
Loading