diff --git a/.gitignore b/.gitignore index 625111a0..3f89e284 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ target/ .gradle/ build/ +### Eclipse ### +bin/ +.metadata + ### STS ### .apt_generated .classpath diff --git a/src/main/java/com/divary/DivaryApplication.java b/src/main/java/com/divary/DivaryApplication.java index bd705b0a..a2130994 100644 --- a/src/main/java/com/divary/DivaryApplication.java +++ b/src/main/java/com/divary/DivaryApplication.java @@ -4,8 +4,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class DivaryApplication { public static void main(String[] args) { diff --git a/src/main/java/com/divary/common/response/ApiResponse.java b/src/main/java/com/divary/common/response/ApiResponse.java index a3eff5bd..384ff308 100644 --- a/src/main/java/com/divary/common/response/ApiResponse.java +++ b/src/main/java/com/divary/common/response/ApiResponse.java @@ -30,6 +30,9 @@ public class ApiResponse { @Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") private String message; + @Schema(description = "요청 경로", example = "/api/v1/example") + private String path; + @Schema(description = "응답 데이터") private T data; @@ -62,6 +65,16 @@ public static ApiResponse error(int status, String code, String message) .build(); } + public static ApiResponse error(int status, String code, String message, String path) { + return ApiResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status) + .code(code) + .message(message) + .path(path) + .build(); + } + public static ApiResponse error(ErrorCode errorCode) { return ApiResponse.builder() .timestamp(LocalDateTime.now()) @@ -70,4 +83,14 @@ public static ApiResponse error(ErrorCode errorCode) { .message(errorCode.getMessage()) .build(); } + + public static ApiResponse error(ErrorCode errorCode, String path) { + return ApiResponse.builder() + .timestamp(LocalDateTime.now()) + .status(errorCode.getStatus().value()) + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .path(path) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/chatroom/controller/ChatRoomController.java b/src/main/java/com/divary/domain/chatroom/controller/ChatRoomController.java index dcf64217..eadcdc18 100644 --- a/src/main/java/com/divary/domain/chatroom/controller/ChatRoomController.java +++ b/src/main/java/com/divary/domain/chatroom/controller/ChatRoomController.java @@ -7,15 +7,21 @@ import com.divary.domain.chatroom.dto.response.ChatRoomMessageResponse; import com.divary.domain.chatroom.dto.response.ChatRoomResponse; import com.divary.domain.chatroom.service.ChatRoomService; +import com.divary.global.config.SwaggerConfig.ApiErrorExamples; import com.divary.global.config.SwaggerConfig.ApiSuccessResponse; +import com.divary.global.config.security.CustomUserPrincipal; +import com.divary.global.exception.ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; +@Slf4j @RestController @RequestMapping("chatrooms") @RequiredArgsConstructor @@ -27,16 +33,19 @@ public class ChatRoomController { @PostMapping(consumes = "multipart/form-data") @Operation(summary = "채팅방 메시지 전송", description = "새 채팅방 생성 또는 기존 채팅방에 메시지 전송\n chatRoomId 없으면 새 채팅방 생성\n 보낸 메시지와 AI 응답만 반환") @ApiSuccessResponse(dataType = ChatRoomMessageResponse.class) + @ApiErrorExamples(value = {ErrorCode.CHAT_ROOM_ACCESS_DENIED, ErrorCode.AUTHENTICATION_REQUIRED}) public ApiResponse sendChatRoomMessage( - @Valid @ModelAttribute ChatRoomMessageRequest request) { + @Valid @ModelAttribute ChatRoomMessageRequest request, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { - ChatRoomMessageResponse response = chatRoomService.sendChatRoomMessage(request); + ChatRoomMessageResponse response = chatRoomService.sendChatRoomMessage(request, userPrincipal.getId()); return ApiResponse.success(response); } @GetMapping("/{chatRoomId}") @Operation(summary = "채팅방 상세 조회", description = "채팅방의 상세 정보를 조회합니다.") @ApiSuccessResponse(dataType = ChatRoomDetailResponse.class) + @ApiErrorExamples(value = {ErrorCode.CHAT_ROOM_NOT_FOUND, ErrorCode.CHAT_ROOM_ACCESS_DENIED, ErrorCode.AUTHENTICATION_REQUIRED}) public ApiResponse getChatRoomDetail(@PathVariable Long chatRoomId) { ChatRoomDetailResponse response = chatRoomService.getChatRoomDetail(chatRoomId); return ApiResponse.success(response); @@ -45,10 +54,12 @@ public ApiResponse getChatRoomDetail(@PathVariable Long @GetMapping @Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록을 조회합니다.") - public ApiResponse> getChatRooms() { - // 임시로 사용자 ID 하드코딩 - // TODO: 사용자 ID를 Authorization 헤더에서 가져오도록 수정 - Long userId = 1L; + @ApiSuccessResponse(dataType = ChatRoomResponse.class, isArray = true) + @ApiErrorExamples(value = {ErrorCode.AUTHENTICATION_REQUIRED}) + public ApiResponse> getChatRooms( + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + + Long userId = userPrincipal.getId(); List responses = chatRoomService.getChatRoomsByUserId(userId); return ApiResponse.success(responses); @@ -57,10 +68,10 @@ public ApiResponse> getChatRooms() { @DeleteMapping("/{chatRoomId}") @Operation(summary = "채팅방 삭제", description = "채팅방을 삭제합니다.") @ApiSuccessResponse(dataType = Void.class) - public ApiResponse deleteChatRoom(@PathVariable Long chatRoomId) { - // 임시로 사용자 ID 하드코딩 - // TODO: 사용자 ID를 Authorization 헤더에서 가져오도록 수정 - Long userId = 1L; + @ApiErrorExamples(value = {ErrorCode.CHAT_ROOM_NOT_FOUND, ErrorCode.CHAT_ROOM_ACCESS_DENIED, ErrorCode.AUTHENTICATION_REQUIRED}) + public ApiResponse deleteChatRoom(@PathVariable Long chatRoomId, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId = userPrincipal.getId(); chatRoomService.deleteChatRoom(chatRoomId, userId); return ApiResponse.success(null); @@ -69,11 +80,11 @@ public ApiResponse deleteChatRoom(@PathVariable Long chatRoomId) { @PatchMapping("/{chatRoomId}/title") @Operation(summary = "채팅방 제목 변경", description = "채팅방의 제목을 변경합니다.") @ApiSuccessResponse(dataType = Void.class) + @ApiErrorExamples(value = {ErrorCode.CHAT_ROOM_NOT_FOUND, ErrorCode.CHAT_ROOM_ACCESS_DENIED, ErrorCode.AUTHENTICATION_REQUIRED}) public ApiResponse updateChatRoomTitle(@PathVariable Long chatRoomId, - @Valid @RequestBody ChatRoomTitleUpdateRequest request) { - // 임시로 사용자 ID 하드코딩 - // TODO: 사용자 ID를 Authorization 헤더에서 가져오도록 수정 - Long userId = 1L; + @Valid @RequestBody ChatRoomTitleUpdateRequest request, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId = userPrincipal.getId(); chatRoomService.updateChatRoomTitle(chatRoomId, userId, request.getTitle()); return ApiResponse.success(null); diff --git a/src/main/java/com/divary/domain/chatroom/service/ChatRoomService.java b/src/main/java/com/divary/domain/chatroom/service/ChatRoomService.java index 56d31517..d96ff6f2 100644 --- a/src/main/java/com/divary/domain/chatroom/service/ChatRoomService.java +++ b/src/main/java/com/divary/domain/chatroom/service/ChatRoomService.java @@ -10,13 +10,15 @@ import com.divary.domain.chatroom.entity.ChatRoom; import com.divary.domain.chatroom.repository.ChatRoomRepository; import com.divary.domain.image.dto.response.ImageResponse; -import com.divary.domain.image.entity.ImageType; +import com.divary.domain.image.enums.ImageType; import com.divary.domain.image.service.ImageService; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; import com.divary.common.converter.TypeConverter; import lombok.RequiredArgsConstructor; + +import org.springframework.web.multipart.MultipartFile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,8 +40,7 @@ public class ChatRoomService { // 채팅방 메시지 전송 (새 채팅방 생성 또는 기존 채팅방에 메시지 추가) @Transactional - public ChatRoomMessageResponse sendChatRoomMessage(ChatRoomMessageRequest request) { - Long userId = getCurrentUserId(); + public ChatRoomMessageResponse sendChatRoomMessage(ChatRoomMessageRequest request, Long userId) { ChatRoom chatRoom; List newMessageIds = new java.util.ArrayList<>(); @@ -89,7 +90,7 @@ private ChatRoom createNewChatRoom(Long userId, ChatRoomMessageRequest request) String firstMessageId = (String) metadata.get("lastMessageId"); HashMap userMessage = TypeConverter.castToHashMap(messages.get(firstMessageId)); - processImageUpload(userMessage, request.getImage(), userId, savedChatRoom.getId().toString()); + processImageUpload(userMessage, request.getImage(), userId, savedChatRoom.getId()); messages.put(firstMessageId, userMessage); savedChatRoom.updateMessages(messages); @@ -106,13 +107,13 @@ private ChatRoom addMessageToExistingChatRoom(Long chatRoomId, Long userId, Chat validateChatRoomOwnership(chatRoom, userId); // 새 메시지 추가 - addUserMessageToChatRoom(chatRoom, request); + addUserMessageToChatRoom(chatRoom, request, userId); return chatRoom; } // 사용자 메시지를 채팅방에 추가 - private void addUserMessageToChatRoom(ChatRoom chatRoom, ChatRoomMessageRequest request) { + private void addUserMessageToChatRoom(ChatRoom chatRoom, ChatRoomMessageRequest request, Long userId) { HashMap messages = chatRoom.getMessages(); String newMessageId = messageFactory.generateNextMessageId(messages); @@ -120,7 +121,7 @@ private void addUserMessageToChatRoom(ChatRoom chatRoom, ChatRoomMessageRequest HashMap messageData = messageFactory.createUserMessageData(request.getMessage(), null); // 이미지 처리 - processImageUpload(messageData, request.getImage(), getCurrentUserId(), chatRoom.getId().toString()); + processImageUpload(messageData, request.getImage(), userId, chatRoom.getId()); messages.put(newMessageId, messageData); @@ -157,24 +158,17 @@ private String addAiResponseToMessages(ChatRoom chatRoom, OpenAIResponse aiRespo return nextMessageId; } - - // 현재 사용자 ID 가져오기 - private Long getCurrentUserId() { - // TODO: 사용자 ID를 Authorization 헤더에서 가져오도록 수정 - return 1L; - } // 채팅방 소유자 권한 확인 private void validateChatRoomOwnership(ChatRoom chatRoom, Long userId) { - // TODO: 채팅방 소유자 확인 로직 - 현재는 하드코딩으로 처리 if (!chatRoom.getUserId().equals(userId)) { - throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + throw new BusinessException(ErrorCode.CHAT_ROOM_ACCESS_DENIED); } } // 이미지 업로드 처리 - private void processImageUpload(HashMap messageData, org.springframework.web.multipart.MultipartFile image, Long userId, String chatRoomId) { + private void processImageUpload(HashMap messageData, MultipartFile image, Long userId, Long chatRoomId) { if (image != null && !image.isEmpty()) { ImageResponse imageResponse = imageService.uploadImageByType( ImageType.USER_CHAT, diff --git a/src/main/java/com/divary/domain/encyclopedia/controller/EncyclopediaCardController.java b/src/main/java/com/divary/domain/encyclopedia/controller/EncyclopediaCardController.java index 84c57377..19d7fe2e 100644 --- a/src/main/java/com/divary/domain/encyclopedia/controller/EncyclopediaCardController.java +++ b/src/main/java/com/divary/domain/encyclopedia/controller/EncyclopediaCardController.java @@ -1,21 +1,21 @@ package com.divary.domain.encyclopedia.controller; import com.divary.common.response.ApiResponse; -import com.divary.domain.encyclopedia.dto.AppearanceResponse; import com.divary.domain.encyclopedia.dto.EncyclopediaCardResponse; import com.divary.domain.encyclopedia.dto.EncyclopediaCardSummaryResponse; -import com.divary.domain.encyclopedia.dto.PersonalityResponse; -import com.divary.domain.encyclopedia.dto.SignificantResponse; import com.divary.domain.encyclopedia.service.EncyclopediaCardService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/cards") +@RequestMapping("/cards") @RequiredArgsConstructor public class EncyclopediaCardController { diff --git a/src/main/java/com/divary/domain/encyclopedia/dto/AppearanceResponse.java b/src/main/java/com/divary/domain/encyclopedia/dto/AppearanceResponse.java index 901b6ae6..f4c63784 100644 --- a/src/main/java/com/divary/domain/encyclopedia/dto/AppearanceResponse.java +++ b/src/main/java/com/divary/domain/encyclopedia/dto/AppearanceResponse.java @@ -3,11 +3,10 @@ import com.divary.domain.encyclopedia.embedded.Appearance; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Arrays; +import java.util.List; import lombok.Builder; import lombok.Getter; -import java.util.List; - @Getter @Builder @Schema(description = "도감 생물 외모 응답 DTO") diff --git a/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardResponse.java b/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardResponse.java index f25dce93..dc44c153 100644 --- a/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardResponse.java +++ b/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardResponse.java @@ -1,15 +1,10 @@ package com.divary.domain.encyclopedia.dto; -import com.divary.domain.encyclopedia.entity.EncyclopediaCard; -import com.divary.domain.image.entity.Image; -import com.divary.domain.image.entity.ImageType; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Optional; +import java.util.List; import lombok.Builder; import lombok.Getter; -import java.util.List; - @Getter @Builder @Schema(description = "도감 카드 상세 응답") @@ -48,31 +43,4 @@ public class EncyclopediaCardResponse { @Schema(description = "특이사항 정보") private SignificantResponse significant; - public static EncyclopediaCardResponse from(EncyclopediaCard card) { - return EncyclopediaCardResponse.builder() - .id(card.getId()) - .name(card.getName()) - .type(card.getType().getDescription()) - .size(card.getSize()) - .appearPeriod(card.getAppearPeriod()) - .place(card.getPlace()) - .imageUrls( - card.getImages().stream() - .filter(img -> img.getType() == ImageType.SYSTEM_DOGAM) - .map(Image::getS3Key) - .toList() - ) - .appearance(Optional.ofNullable(card.getAppearance()) - .map(AppearanceResponse::from) - .orElse(null)) - .personality(Optional.ofNullable(card.getPersonality()) - .map(PersonalityResponse::from) - .orElse(null)) - .significant(Optional.ofNullable(card.getSignificant()) - .map(SignificantResponse::from) - .orElse(null)) - .build(); - } - - -} +} \ No newline at end of file diff --git a/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardSummaryResponse.java b/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardSummaryResponse.java index 692fde8f..ad6f79db 100644 --- a/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardSummaryResponse.java +++ b/src/main/java/com/divary/domain/encyclopedia/dto/EncyclopediaCardSummaryResponse.java @@ -1,9 +1,6 @@ package com.divary.domain.encyclopedia.dto; -import com.divary.domain.encyclopedia.entity.EncyclopediaCard; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Collections; -import java.util.List; import lombok.Builder; import lombok.Getter; @@ -21,19 +18,7 @@ public class EncyclopediaCardSummaryResponse { @Schema(description = "생물 종류", example = "어류") private String type; - @Schema(description = "썸네일 이미지 URL 목록", example = "[\"https://s3.example.com/card1-thumbnail.jpg\"]") - private List imageUrls; + @Schema(description = "도감 이모지 프로필 URL", example = "\"https://s3.example.com/card1-thumbnail.jpg\"") + private String dogamProfileUrl; - public static EncyclopediaCardSummaryResponse from(EncyclopediaCard card) { - return EncyclopediaCardSummaryResponse.builder() - .id(card.getId()) - .name(card.getName()) - .type(card.getType().getDescription()) - .imageUrls( - card.getThumbnail() != null - ? Collections.singletonList(card.getThumbnail().getS3Key()) - : Collections.emptyList() - ) - .build(); - } } diff --git a/src/main/java/com/divary/domain/encyclopedia/entity/EncyclopediaCard.java b/src/main/java/com/divary/domain/encyclopedia/entity/EncyclopediaCard.java index 15c7add8..6cc793a8 100644 --- a/src/main/java/com/divary/domain/encyclopedia/entity/EncyclopediaCard.java +++ b/src/main/java/com/divary/domain/encyclopedia/entity/EncyclopediaCard.java @@ -5,11 +5,13 @@ import com.divary.domain.encyclopedia.embedded.Personality; import com.divary.domain.encyclopedia.embedded.Significant; import com.divary.domain.encyclopedia.enums.Type; -import com.divary.domain.image.entity.Image; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.*; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -38,23 +40,6 @@ public class EncyclopediaCard extends BaseEntity { @Schema(description = "서식지", example = "연안 암초 지역") private String place; - // TODO: 연관관계 제거 예정, ImageType 기반 조회로 대체하겠습니다. - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "thumbnail_id") - @Schema(description = "도감 프로필 썸네일 이미지 (ImageType = DOGAM_PROFILE)") - private Image thumbnail; - - // TODO: 연관관계 제거 예정, ImageType 기반 조회로 대체 - @OneToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "encyclopedia_card_images", - joinColumns = @JoinColumn(name = "card_id"), - inverseJoinColumns = @JoinColumn(name = "image_id") - ) - - @Schema(description = "도감 카드에 포함된 이미지들 (ImageType = DOGAM)") - private List images = new ArrayList<>(); - @Embedded @Schema(description = "외모 값 객체") private Appearance appearance; diff --git a/src/main/java/com/divary/domain/encyclopedia/service/EncyclopediaCardService.java b/src/main/java/com/divary/domain/encyclopedia/service/EncyclopediaCardService.java index 9e20f8ee..e7854885 100644 --- a/src/main/java/com/divary/domain/encyclopedia/service/EncyclopediaCardService.java +++ b/src/main/java/com/divary/domain/encyclopedia/service/EncyclopediaCardService.java @@ -1,14 +1,23 @@ package com.divary.domain.encyclopedia.service; +import com.divary.domain.encyclopedia.dto.AppearanceResponse; import com.divary.domain.encyclopedia.dto.EncyclopediaCardResponse; import com.divary.domain.encyclopedia.dto.EncyclopediaCardSummaryResponse; +import com.divary.domain.encyclopedia.dto.PersonalityResponse; +import com.divary.domain.encyclopedia.dto.SignificantResponse; import com.divary.domain.encyclopedia.entity.EncyclopediaCard; import com.divary.domain.encyclopedia.enums.Type; import com.divary.domain.encyclopedia.repository.EncyclopediaCardRepository; +import com.divary.domain.image.dto.response.ImageResponse; +import com.divary.domain.image.enums.ImageType; +import com.divary.domain.image.service.ImageService; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +27,7 @@ public class EncyclopediaCardService { private final EncyclopediaCardRepository encyclopediaCardRepository; + private final ImageService imageService; private static Type convertDescriptionToEnum(String description) { return Arrays.stream(Type.values()) @@ -32,20 +42,38 @@ private static boolean isValidDescription(String description) { } @Transactional(readOnly = true) - public List getCards(String description) { + public List getCards(String description) { + List cards; if (description == null) { - return encyclopediaCardRepository.findAll().stream() - .map(EncyclopediaCardSummaryResponse::from) - .toList(); + cards = encyclopediaCardRepository.findAll(); + } else { + if (!isValidDescription(description)) { + throw new BusinessException(ErrorCode.TYPE_NOT_FOUND); + } + Type typeEnum = convertDescriptionToEnum(description); + cards = encyclopediaCardRepository.findAllByType(typeEnum); } - if (!isValidDescription(description)) { - throw new BusinessException(ErrorCode.TYPE_NOT_FOUND); - } + // 모든 도감 프로필 (도감 이모티콘) 한 번에 조회 + List allDogamProfiles = imageService.getImagesByType(ImageType.SYSTEM_DOGAM_PROFILE, null, null); + + // cardId -> FileUrl 매핑 + Map dogamProfileMap = allDogamProfiles.stream() + .collect( + Collectors.toMap(img -> + Long.valueOf(img.getS3Key().split("/")[2]), + ImageResponse::getFileUrl, + (v1, v2) -> v1 ) // 혹시라도 동일 cardId에 도감 프로필이 여러 개 있을 경우, 첫 번째 값만 사용 + ); - Type typeEnum = convertDescriptionToEnum(description); - return encyclopediaCardRepository.findAllByType(typeEnum).stream() - .map(EncyclopediaCardSummaryResponse::from) + return cards.stream() + .map(card -> + EncyclopediaCardSummaryResponse.builder() + .id(card.getId()) + .name(card.getName()) + .type(card.getType().getDescription()) + .dogamProfileUrl(dogamProfileMap.get(card.getId())) + .build()) .toList(); } @@ -53,7 +81,28 @@ public List getCards(String description) { public EncyclopediaCardResponse getDetail(Long id) { EncyclopediaCard card = encyclopediaCardRepository.findById(id) .orElseThrow(() -> new BusinessException(ErrorCode.CARD_NOT_FOUND)); - return EncyclopediaCardResponse.from(card); + + List imageUrls = imageService.getImagesByType( + ImageType.SYSTEM_DOGAM, + null, + card.getId() + ).stream() + .map(ImageResponse::getFileUrl) + .toList(); + + return EncyclopediaCardResponse.builder() + .id(card.getId()) + .name(card.getName()) + .type(card.getType().getDescription()) + .size(card.getSize()) + .appearPeriod(card.getAppearPeriod()) + .place(card.getPlace()) + .imageUrls(imageUrls) + .appearance(Optional.ofNullable(card.getAppearance()).map(AppearanceResponse::from).orElse(null)) + .personality(Optional.ofNullable(card.getPersonality()).map(PersonalityResponse::from).orElse(null)) + .significant(Optional.ofNullable(card.getSignificant()).map(SignificantResponse::from).orElse(null)) + .build(); } + } diff --git a/src/main/java/com/divary/domain/image/controller/ImageController.java b/src/main/java/com/divary/domain/image/controller/ImageController.java index 2d6f0cb0..bb3b3c00 100644 --- a/src/main/java/com/divary/domain/image/controller/ImageController.java +++ b/src/main/java/com/divary/domain/image/controller/ImageController.java @@ -1,10 +1,11 @@ package com.divary.domain.image.controller; import com.divary.common.response.ApiResponse; -import com.divary.domain.image.dto.request.ImageUploadRequest; import com.divary.domain.image.dto.response.ImageResponse; -import com.divary.domain.image.entity.ImageType; +import com.divary.domain.image.dto.response.MultipleImageUploadResponse; +import com.divary.domain.image.enums.ImageType; import com.divary.domain.image.service.ImageService; +import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.config.SwaggerConfig.ApiErrorExamples; import com.divary.global.exception.ErrorCode; import io.swagger.v3.oas.annotations.Operation; @@ -14,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,32 +24,27 @@ @Tag(name = "Image", description = "이미지 업로드 및 관리") @Slf4j @RestController -@RequestMapping("images") +@RequestMapping("/images") @RequiredArgsConstructor public class ImageController { private final ImageService imageService; - @Operation(summary = "이미지 업로드", description = "S3에 이미지를 업로드하고 정보를 저장합니다.") + @Operation(summary = "임시 이미지 업로드", description = "임시 경로에 다중 이미지를 업로드합니다. 24시간 후 자동 삭제됩니다.") @ApiErrorExamples({ - ErrorCode.VALIDATION_ERROR, - ErrorCode.INTERNAL_SERVER_ERROR + ErrorCode.REQUIRED_FIELD_MISSING, + ErrorCode.IMAGE_SIZE_TOO_LARGE, + ErrorCode.IMAGE_FORMAT_NOT_SUPPORTED, + ErrorCode.AUTHENTICATION_REQUIRED }) - @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadImage( - @Parameter(description = "업로드할 이미지 파일", required = true, - content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) - @RequestPart("file") MultipartFile file, - - @Parameter(description = "S3 업로드 경로", required = true, example = "users/1/chat/10/") - @RequestParam("uploadPath") String uploadPath) { - ImageUploadRequest request = ImageUploadRequest.builder() - .file(file) - .uploadPath(uploadPath) - .build(); - - ImageResponse response = imageService.uploadImage(request); - return ApiResponse.success("이미지 업로드가 완료되었습니다.", response); + @PostMapping(value = "/upload/temp", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadTempImages( + @Parameter(description = "업로드할 이미지 파일들 (최대 10개)", required = true) + @RequestPart("files") List files, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + + MultipleImageUploadResponse response = imageService.uploadTempImages(files, userPrincipal.getId()); + return ApiResponse.success("임시 이미지 업로드가 완료되었습니다. 24시간 내에 사용하지 않으면 자동 삭제됩니다.", response); } @Operation(summary = "이미지 삭제", description = "S3와 DB에서 이미지를 삭제합니다.") @@ -93,14 +90,14 @@ public ApiResponse uploadImageByType( @Parameter(description = "사용자 ID (USER 타입의 경우 필수)", example = "1") @RequestParam(required = false) Long userId, - @Parameter(description = "추가 경로 (선택사항)", example = "additional/path") - @RequestParam(required = false) String additionalPath + @Parameter(description = "추가 경로 (선택사항)", example = "1") + @RequestParam(required = false) Long postId ) { - ImageResponse response = imageService.uploadImageByType(imageType, file, userId, additionalPath); + ImageResponse response = imageService.uploadImageByType(imageType, file, userId, postId); return ApiResponse.success("타입별 이미지 업로드가 완료되었습니다.", response); } - @Operation(summary = "이미지 타입별 상세 정보 조회", description = "ImageType을 기준으로 해당 타입의 이미지 상세 정보를 조회합니다.") + @Operation(summary = "이미지 타입별 상세 정보 조회", description = "ImageType을 기준으로 해당 타입의 이미지 상세 정보를 조회합니다. 시스템 타입은 추가 경로 없이 조회 가능합니다.") @ApiErrorExamples({ ErrorCode.INVALID_INPUT_VALUE, ErrorCode.INTERNAL_SERVER_ERROR @@ -111,10 +108,10 @@ public ApiResponse> getImagesByType( @PathVariable ImageType imageType, @Parameter(description = "사용자 ID (USER 타입의 경우 필수)", example = "1") @RequestParam(required = false) Long userId, - @Parameter(description = "추가 경로 (선택사항)", example = "additional/path") - @RequestParam(required = false) String additionalPath + @Parameter(description = "추가 경로 (선택사항)", example = "1") + @RequestParam(required = false) Long postId ) { - List images = imageService.getImagesByType(imageType, userId, additionalPath); + List images = imageService.getImagesByType(imageType, userId, postId); return ApiResponse.success("이미지 타입별 상세 정보를 조회했습니다.", images); } } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/dto/response/ImageResponse.java b/src/main/java/com/divary/domain/image/dto/response/ImageResponse.java index 871634eb..094416e3 100644 --- a/src/main/java/com/divary/domain/image/dto/response/ImageResponse.java +++ b/src/main/java/com/divary/domain/image/dto/response/ImageResponse.java @@ -1,15 +1,15 @@ package com.divary.domain.image.dto.response; import com.divary.domain.image.entity.Image; -import com.divary.domain.image.entity.ImageType; +import com.divary.domain.image.enums.ImageType; + import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; - @Data @Builder @NoArgsConstructor @@ -43,6 +43,9 @@ public class ImageResponse { @Schema(description = "사용자 ID", example = "1") private Long userId; + + @Schema(description = "S3 저장 키", example = "system/dogam_profile/1/filename.jpg") + private String s3Key; public static ImageResponse from(Image image, String fileUrl) { return ImageResponse.builder() @@ -55,6 +58,7 @@ public static ImageResponse from(Image image, String fileUrl) { .createdAt(image.getCreatedAt()) .updatedAt(image.getUpdatedAt()) .userId(image.getUserId()) + .s3Key(image.getS3Key()) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/dto/response/MultipleImageUploadResponse.java b/src/main/java/com/divary/domain/image/dto/response/MultipleImageUploadResponse.java new file mode 100644 index 00000000..59d3d3af --- /dev/null +++ b/src/main/java/com/divary/domain/image/dto/response/MultipleImageUploadResponse.java @@ -0,0 +1,30 @@ +package com.divary.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@Schema(description = "다중 이미지 업로드 응답") +public class MultipleImageUploadResponse { + + @Schema(description = "업로드된 이미지들의 정보") + private List images; + + @Schema(description = "업로드 성공한 이미지 개수") + private int successCount; + + @Schema(description = "업로드 실패한 이미지 개수") + private int failureCount; + + public static MultipleImageUploadResponse of(List images) { + return MultipleImageUploadResponse.builder() + .images(images) + .successCount(images.size()) + .failureCount(0) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/entity/Image.java b/src/main/java/com/divary/domain/image/entity/Image.java index b01bbf15..113c7ad4 100644 --- a/src/main/java/com/divary/domain/image/entity/Image.java +++ b/src/main/java/com/divary/domain/image/entity/Image.java @@ -1,6 +1,8 @@ package com.divary.domain.image.entity; import com.divary.common.entity.BaseEntity; +import com.divary.domain.image.enums.ImageType; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.AccessLevel; @@ -40,14 +42,19 @@ public class Image extends BaseEntity { @Schema(description = "업로드한 사용자 ID", example = "1") private Long userId; + @Column(name = "post_id") + @Schema(description = "연결된 게시글 ID", example = "123") + private Long postId; + @Builder - public Image(String s3Key, ImageType type, String originalFilename, Long width, Long height, Long userId) { + public Image(String s3Key, ImageType type, String originalFilename, Long width, Long height, Long userId, Long postId) { this.s3Key = s3Key; this.type = type; this.originalFilename = originalFilename; this.width = width; this.height = height; this.userId = userId; + this.postId = postId; } // 이미지 정보 업데이트 @@ -59,4 +66,14 @@ public void updateDimensions(Long width, Long height) { public void updateType(ImageType type) { this.type = type; } + + // S3 키 업데이트 + public void updateS3Key(String s3Key) { + this.s3Key = s3Key; + } + + // 게시글 ID 업데이트 (temp → permanent 변환 시) + public void updatePostId(Long postId) { + this.postId = postId; + } } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/entity/ImageType.java b/src/main/java/com/divary/domain/image/enums/ImageType.java similarity index 74% rename from src/main/java/com/divary/domain/image/entity/ImageType.java rename to src/main/java/com/divary/domain/image/enums/ImageType.java index 7a7a2820..2333996d 100644 --- a/src/main/java/com/divary/domain/image/entity/ImageType.java +++ b/src/main/java/com/divary/domain/image/enums/ImageType.java @@ -1,4 +1,4 @@ -package com.divary.domain.image.entity; +package com.divary.domain.image.enums; import lombok.Getter; @@ -9,6 +9,9 @@ public enum ImageType { USER_CHAT, USER_LICENSE, + // 테스트용 이미지 타입 + USER_TEST_POST, + // 시스템 이미지 (모든 유저에게 공통인 것들을 처리하면 됩니다.) SYSTEM_DOGAM, SYSTEM_DOGAM_PROFILE; diff --git a/src/main/java/com/divary/domain/image/repository/ImageRepository.java b/src/main/java/com/divary/domain/image/repository/ImageRepository.java index fbb80175..070ace3d 100644 --- a/src/main/java/com/divary/domain/image/repository/ImageRepository.java +++ b/src/main/java/com/divary/domain/image/repository/ImageRepository.java @@ -1,11 +1,16 @@ package com.divary.domain.image.repository; import com.divary.domain.image.entity.Image; -import com.divary.domain.image.entity.ImageType; +import com.divary.domain.image.enums.ImageType; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository public interface ImageRepository extends JpaRepository { @@ -20,8 +25,18 @@ public interface ImageRepository extends JpaRepository { List findByUserIdAndType(Long userId, ImageType type); // S3 키로 이미지 조회 - Image findByS3Key(String s3Key); + Optional findByS3Key(String s3Key); // S3 키가 특정 패턴으로 시작하는 이미지 목록 조회 List findByS3KeyStartingWith(String s3KeyPrefix); + + // 특정 타입과 게시글 ID로 이미지 조회 + List findByTypeAndPostId(ImageType type, Long postId); + + // postId가 null인 temp 이미지들 조회 + List findByPostIdIsNull(); + + // 24시간이 지난 temp 경로 고아 이미지들 조회 (postId가 null이고 생성일이 기준 시간 이전, temp 경로 포함) + @Query("SELECT i FROM Image i WHERE i.postId IS NULL AND i.createdAt < :cutoffTime AND i.s3Key LIKE '%/temp/%'") + List findOrphanedTempImages(@Param("cutoffTime") LocalDateTime cutoffTime); } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/service/ImageCleanupService.java b/src/main/java/com/divary/domain/image/service/ImageCleanupService.java new file mode 100644 index 00000000..cba395bf --- /dev/null +++ b/src/main/java/com/divary/domain/image/service/ImageCleanupService.java @@ -0,0 +1,143 @@ +package com.divary.domain.image.service; + +import com.divary.domain.image.entity.Image; +import com.divary.domain.image.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageCleanupService { + + private final ImageRepository imageRepository; + private final ImageStorageService imageStorageService; + + /** + * 매일 한국시간 새벽 3시에 고아 이미지들을 삭제 + * 1. DB의 고아 이미지: postId가 null이고 s3Key에 '/temp/' 경로가 포함되며 생성된 지 24시간이 지난 이미지 + * 2. S3의 고아 파일: S3에는 있지만 DB에는 없는 temp 경로의 파일들 + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + @Transactional + public void cleanupOrphanedImages() { + log.info("고아 이미지 삭제 작업을 시작합니다."); + + try { + // 1. DB의 temp 경로 고아 이미지들 삭제 + cleanupDbOrphanedTempImages(); + + // 2. S3의 고아 파일들 삭제 + cleanupS3OrphanedFiles(); + + log.info("고아 이미지 삭제 작업이 완료되었습니다."); + + } catch (Exception e) { + log.error("고아 이미지 삭제 작업 중 오류가 발생했습니다.", e); + } + } + + private void cleanupDbOrphanedTempImages() { + log.info("DB의 temp 경로 고아 이미지 삭제를 시작합니다."); + + // 24시간 전 시간 계산 + LocalDateTime cutoffTime = LocalDateTime.now().minusHours(24); + log.info("삭제 기준 시간: {}", cutoffTime); + + // 삭제 대상 temp 경로 고아 이미지들 조회 + List orphanedTempImages = imageRepository.findOrphanedTempImages(cutoffTime); + + if (orphanedTempImages.isEmpty()) { + log.info("삭제할 DB의 temp 경로 고아 이미지가 없습니다."); + return; + } + + log.info("{}개의 DB temp 경로 고아 이미지를 삭제합니다.", orphanedTempImages.size()); + + int successCount = 0; + int failureCount = 0; + + // 각 이미지에 대해 S3와 DB에서 삭제 + for (Image image : orphanedTempImages) { + try { + // S3에서 이미지 파일 삭제 + imageStorageService.deleteFromS3(image.getS3Key()); + + // DB에서 이미지 정보 삭제 + imageRepository.delete(image); + + successCount++; + log.debug("DB temp 이미지 삭제 완료: ID={}, S3Key={}", image.getId(), image.getS3Key()); + + } catch (Exception e) { + failureCount++; + log.error("DB temp 이미지 삭제 실패: ID={}, S3Key={}, 오류={}", + image.getId(), image.getS3Key(), e.getMessage(), e); + } + } + + log.info("DB temp 경로 고아 이미지 삭제가 완료되었습니다. 성공: {}개, 실패: {}개", successCount, failureCount); + } + + private void cleanupS3OrphanedFiles() { + log.info("S3 고아 파일 삭제를 시작합니다."); + + // S3에서 temp 경로의 모든 파일 목록 조회 + String tempPrefix = "users/"; + List s3TempFiles = imageStorageService.listTempFiles(tempPrefix) + .stream() + .filter(key -> key.contains("/temp/")) + .toList(); + + if (s3TempFiles.isEmpty()) { + log.info("S3에 temp 파일이 없습니다."); + return; + } + + log.info("S3에서 {}개의 temp 파일을 발견했습니다.", s3TempFiles.size()); + + // DB에 존재하는 S3 키 목록 조회 + Set dbS3Keys = imageRepository.findAll() + .stream() + .map(Image::getS3Key) + .collect(Collectors.toSet()); + + // S3에는 있지만 DB에는 없는 고아 파일들 찾기 + List orphanedS3Files = s3TempFiles.stream() + .filter(s3Key -> !dbS3Keys.contains(s3Key)) + .toList(); + + if (orphanedS3Files.isEmpty()) { + log.info("삭제할 S3 고아 파일이 없습니다."); + return; + } + + log.info("{}개의 S3 고아 파일을 삭제합니다.", orphanedS3Files.size()); + + int successCount = 0; + int failureCount = 0; + + // S3에서 고아 파일들 삭제 + for (String s3Key : orphanedS3Files) { + try { + imageStorageService.deleteFromS3(s3Key); + successCount++; + log.debug("S3 고아 파일 삭제 완료: {}", s3Key); + + } catch (Exception e) { + failureCount++; + log.error("S3 고아 파일 삭제 실패: S3Key={}, 오류={}", s3Key, e.getMessage(), e); + } + } + + log.info("S3 고아 파일 삭제가 완료되었습니다. 성공: {}개, 실패: {}개", successCount, failureCount); + } +} \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/service/ImagePathService.java b/src/main/java/com/divary/domain/image/service/ImagePathService.java index f0aea120..1f946544 100644 --- a/src/main/java/com/divary/domain/image/service/ImagePathService.java +++ b/src/main/java/com/divary/domain/image/service/ImagePathService.java @@ -1,16 +1,37 @@ package com.divary.domain.image.service; -import com.divary.domain.image.entity.ImageType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import com.divary.domain.image.enums.ImageType; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j @Service -// 이미지 경로 생성 서비스 +@RequiredArgsConstructor public class ImagePathService { + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${cloud.aws.region.static}") + private String region; + + private static Pattern tempUrlPattern = null; + + private final ImageValidationService imageValidationService; + // 유저 이미지 업로드 경로 생성 public String generateUserUploadPath(ImageType imageType, Long userId, String additionalPath) { - validateUserImageType(imageType); - validateUserId(userId); + imageValidationService.validateUserImageType(imageType); + imageValidationService.validateUserId(userId); String typeWithoutPrefix = extractTypeWithoutPrefix(imageType.name(), "USER_"); @@ -28,7 +49,7 @@ public String generateUserUploadPath(ImageType imageType, Long userId, String ad // 시스템 이미지 업로드 경로 생성 public String generateSystemUploadPath(ImageType imageType, String additionalPath) { - validateSystemImageType(imageType); + imageValidationService.validateSystemImageType(imageType); String typeWithoutPrefix = extractTypeWithoutPrefix(imageType.name(), "SYSTEM_"); @@ -42,6 +63,14 @@ public String generateSystemUploadPath(ImageType imageType, String additionalPat return path.toString(); } + // temp 경로 생성 + public String generateTempPath(Long userId) { + imageValidationService.validateUserId(userId); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String randomId = UUID.randomUUID().toString().substring(0, 8); + return String.format("users/%d/temp/%s_%s", userId, timestamp, randomId); + } + // 경로에서 사용자 ID 추출 public Long extractUserIdFromPath(String uploadPath) { if (uploadPath != null && uploadPath.startsWith("users/")) { @@ -57,22 +86,78 @@ public Long extractUserIdFromPath(String uploadPath) { return null; } - private void validateUserImageType(ImageType imageType) { - if (!imageType.name().startsWith("USER_")) { - throw new IllegalArgumentException("USER 타입 이미지만 처리 가능합니다: " + imageType); + // S3 이미지 URL 패턴 생성 + private String getS3ImageUrlPattern(String pathPattern) { + return String.format( + "https://%s\\.s3\\.%s\\.amazonaws\\.com/%s\\.(jpg|jpeg|png|gif|webp)", + java.util.regex.Pattern.quote(bucketName), + java.util.regex.Pattern.quote(region), + pathPattern); + } + + // temp 이미지 URL 패턴 생성 + public String getTempImageUrlPattern() { + return getS3ImageUrlPattern("users/\\d+/temp/.*?"); + } + + // 모든 이미지 URL 패턴 생성 (temp + permanent) + public String getAllImageUrlPattern() { + return getS3ImageUrlPattern("[^\\\\s\"'<>]+"); + } + + // 본문에서 temp 이미지 URL 패턴 추출 + public List extractTempImageUrls(String content) { + if (tempUrlPattern == null) { + String pattern = getTempImageUrlPattern(); + log.info("temp URL 정규식 패턴: {}", pattern); + tempUrlPattern = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE); + } + + log.info("컨텐츠에서 temp URL 추출 시도: {}", content); + + Matcher matcher = tempUrlPattern.matcher(content); + List tempUrls = new ArrayList<>(); + while (matcher.find()) { + String foundUrl = matcher.group(); + tempUrls.add(foundUrl); + log.info("temp URL 발견: {}", foundUrl); } + + log.info("총 발견된 temp URL 개수: {}", tempUrls.size()); + return tempUrls; } - private void validateSystemImageType(ImageType imageType) { - if (!imageType.name().startsWith("SYSTEM_")) { - throw new IllegalArgumentException("SYSTEM 타입 이미지만 처리 가능합니다: " + imageType); + // 컨텐츠에서 모든 이미지 URL 추출 (temp 이미지뿐만 아니라 permanent 이미지도) + public List extractAllImageUrls(String content) { + if (content == null || content.trim().isEmpty()) { + return new ArrayList<>(); } + + String allImagePattern = getAllImageUrlPattern(); + Pattern pattern = Pattern.compile(allImagePattern, Pattern.CASE_INSENSITIVE); + + Matcher matcher = pattern.matcher(content); + List imageUrls = new ArrayList<>(); + while (matcher.find()) { + imageUrls.add(matcher.group()); + } + + log.debug("컨텐츠에서 추출된 이미지 URL 개수: {}", imageUrls.size()); + return imageUrls; } - private void validateUserId(Long userId) { - if (userId == null) { - throw new IllegalArgumentException("사용자 ID는 필수입니다."); + // 본문의 temp URL을 permanent URL로 교체 + public String replaceTempUrls(String content, Map urlMappings) { + String result = content; + for (Map.Entry entry : urlMappings.entrySet()) { + result = result.replace(entry.getKey(), entry.getValue()); } + return result; + } + + // temp 경로 이미지인지 확인 + public boolean isTempImage(String s3Key) { + return s3Key != null && s3Key.contains("/temp/"); } private String extractTypeWithoutPrefix(String typeName, String prefix) { diff --git a/src/main/java/com/divary/domain/image/service/ImageService.java b/src/main/java/com/divary/domain/image/service/ImageService.java index 084dc5b3..633afcf0 100644 --- a/src/main/java/com/divary/domain/image/service/ImageService.java +++ b/src/main/java/com/divary/domain/image/service/ImageService.java @@ -2,8 +2,9 @@ import com.divary.domain.image.dto.request.ImageUploadRequest; import com.divary.domain.image.dto.response.ImageResponse; +import com.divary.domain.image.dto.response.MultipleImageUploadResponse; import com.divary.domain.image.entity.Image; -import com.divary.domain.image.entity.ImageType; +import com.divary.domain.image.enums.ImageType; import com.divary.domain.image.repository.ImageRepository; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; @@ -11,11 +12,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.IOException; -import java.util.List; +import java.time.LocalDateTime; +import java.util.*; import java.util.stream.Collectors; @@ -25,24 +28,162 @@ @Transactional(readOnly = true) public class ImageService { + // 상수 정의 + private static final int TEMP_IMAGE_EXPIRY_HOURS = 24; + private final ImageRepository imageRepository; private final ImageStorageService imageStorageService; private final ImagePathService imagePathService; + private final ImageValidationService imageValidationService; + + // 다중 임시 이미지 업로드 + @Transactional + public MultipleImageUploadResponse uploadTempImages(List files, Long userId) { + imageValidationService.validateMultipleFiles(files); + + List uploadedImages = new ArrayList<>(); + + for (MultipartFile file : files) { + try { + ImageResponse imageResponse = uploadSingleTempImage(file, userId); + uploadedImages.add(imageResponse); + } catch (Exception e) { + log.error("임시 이미지 업로드 실패: {}, 사용자: {}", file.getOriginalFilename(), userId, e); + } + } + + log.info("임시 이미지 업로드 완료 - 성공: {}/{}, 사용자: {}", + uploadedImages.size(), files.size(), userId); + + return MultipleImageUploadResponse.of(uploadedImages); + } + + // 단일 임시 이미지 업로드 + private ImageResponse uploadSingleTempImage(MultipartFile file, Long userId) { + try { + ImageMetadata metadata = extractImageMetadata(file); + + // temp 경로 생성 + String tempPath = imagePathService.generateTempPath(userId); + String fileName = imageStorageService.generateUniqueFileName(file.getOriginalFilename()); + String tempS3Key = tempPath + "/" + fileName; + + // S3 업로드 + imageStorageService.uploadToS3(tempS3Key, file); + + // DB 저장 (temp 이미지는 postId null) + Image tempImage = Image.builder() + .s3Key(tempS3Key) + .type(null) + .originalFilename(file.getOriginalFilename()) + .width(metadata.width) + .height(metadata.height) + .userId(userId) + .postId(null) + .build(); + + Image savedImage = imageRepository.save(tempImage); + String tempUrl = imageStorageService.generatePublicUrl(tempS3Key); + + return ImageResponse.from(savedImage, tempUrl); + + } catch (IOException e) { + log.error("이미지 처리 중 오류 발생: {}", e.getMessage()); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + // 본문에서 temp 이미지 URL을 찾아서 permanent URL로 변환 + @Transactional + public String processContentAndMigrateImages(String content, ImageType imageType, Long userId, Long postId) { + if (content == null || content.trim().isEmpty()) { + return content; + } + + List tempUrls = imagePathService.extractTempImageUrls(content); + if (tempUrls.isEmpty()) { + return content; + } + + Map urlMappings = new HashMap<>(); + for (String tempUrl : tempUrls) { + try { + String permanentUrl = migrateTempImageByUrl(tempUrl, imageType, userId, postId); + urlMappings.put(tempUrl, permanentUrl); + } catch (Exception e) { + log.error("이미지 이동 실패: tempUrl={}, userId={}", tempUrl, userId, e); + } + } + + return imagePathService.replaceTempUrls(content, urlMappings); + } + + // 게시글 수정 시 삭제된 이미지 처리 + @Transactional + public void processDeletedImagesAfterPostUpdate(ImageType imageType, Long postId, String newContent) { + // 현재 게시글에 연결된 이미지 목록 조회 + List currentImages = imageRepository.findByTypeAndPostId(imageType, postId); + + if (currentImages.isEmpty()) { + return; + } + + // 새 컨텐츠에서 이미지 URL 추출 + List newImageUrls = imagePathService.extractAllImageUrls(newContent); + + // 삭제할 이미지 찾기 (현재 DB에는 있지만 새 컨텐츠에는 없는 이미지) + List imagesToDelete = currentImages.stream() + .filter(image -> { + String imageUrl = imageStorageService.generatePublicUrl(image.getS3Key()); + return !newImageUrls.contains(imageUrl); + }) + .collect(Collectors.toList()); + + // 삭제 처리 + for (Image imageToDelete : imagesToDelete) { + try { + deleteImage(imageToDelete.getId()); + log.info("게시글 수정으로 인한 이미지 삭제: imageId={}, postId={}, imageType={}", + imageToDelete.getId(), postId, imageType); + } catch (Exception e) { + log.error("이미지 삭제 실패: imageId={}, postId={}", imageToDelete.getId(), postId, e); + } + } + } + + // 통합 이미지 처리 메서드 - 게시글 생성/수정 시 사용 (다른 서비스에서 사용해야하는 메서드) + @Transactional + public String processContentAndUpdateImages(String content, ImageType imageType, Long userId, Long postId, String previousContent) { + if (content == null || content.trim().isEmpty()) { + // 컨텐츠가 비어있지만 이전 컨텐츠가 있다면 기존 이미지들 삭제 + if (previousContent != null && !previousContent.trim().isEmpty()) { + processDeletedImagesAfterPostUpdate(imageType, postId, ""); + } + return content; + } + // 새로 추가된 임시 이미지를 영구 경로로 마이그레이션 + String updatedContent = processContentAndMigrateImages(content, imageType, userId, postId); + + // 기존 이미지 중 더 이상 사용되지 않는 것들 삭제 (수정 시에만) + if (previousContent != null) { + processDeletedImagesAfterPostUpdate(imageType, postId, updatedContent); + } + + log.info("통합 이미지 처리 완료: postId={}, imageType={}, userId={}", postId, imageType, userId); + + return updatedContent; + } + // 이미지 업로드 메인 로직 @Transactional public ImageResponse uploadImage(ImageUploadRequest request) { - validateUploadRequest(request); + imageValidationService.validateUploadRequest(request); try { - // 이미지 크기 추출 (바이트 배열로 변환하여 스트림 재사용 가능하게 함) - // byte[] fileBytes = request.getFile().getBytes(); - // BufferedImage bufferedImage = ImageIO.read(new java.io.ByteArrayInputStream(fileBytes)); - BufferedImage bufferedImage = ImageIO.read(request.getFile().getInputStream()); - Long width = bufferedImage != null ? (long) bufferedImage.getWidth() : null; - Long height = bufferedImage != null ? (long) bufferedImage.getHeight() : null; + ImageMetadata metadata = extractImageMetadata(request.getFile()); - log.debug("이미지 크기 - width: {}, height: {}, bufferedImage: {}", width, height, bufferedImage != null); + log.debug("이미지 크기 - width: {}, height: {}", metadata.width, metadata.height); // 파일명 생성 String fileName = imageStorageService.generateUniqueFileName(request.getFile().getOriginalFilename()); @@ -58,9 +199,10 @@ public ImageResponse uploadImage(ImageUploadRequest request) { .s3Key(s3Key) .type(null) .originalFilename(request.getFile().getOriginalFilename()) - .width(width) - .height(height) + .width(metadata.width) + .height(metadata.height) .userId(imagePathService.extractUserIdFromPath(request.getUploadPath())) + .postId(null) .build(); Image savedImage = imageRepository.save(image); @@ -104,43 +246,16 @@ public void deleteImage(Long imageId) { imageRepository.delete(image); } - private void validateUploadRequest(ImageUploadRequest request) { - // 파일 검증 - if (request.getFile() == null || request.getFile().isEmpty()) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR); - } - - // 업로드 경로 검증 - if (request.getUploadPath() == null || request.getUploadPath().trim().isEmpty()) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR); - } - - // 경로 보안 검증 (../ 등 상위 디렉토리 접근 차단) - if (request.getUploadPath().contains("..") || request.getUploadPath().startsWith("/")) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR); - } - - // 파일 크기 검증 (10MB 제한) - if (request.getFile().getSize() > 10 * 1024 * 1024) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR); - } - - // 파일 타입 검증 - String contentType = request.getFile().getContentType(); - if (contentType == null || !contentType.startsWith("image/")) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR); - } - } // 타입별 이미지 업로드 @Transactional - public ImageResponse uploadImageByType(ImageType imageType, org.springframework.web.multipart.MultipartFile file, Long userId, String additionalPath) { + public ImageResponse uploadImageByType(ImageType imageType, MultipartFile file, Long userId, Long postId) { // 업로드 경로 생성 String uploadPath; if (imageType.name().startsWith("USER_")) { - uploadPath = imagePathService.generateUserUploadPath(imageType, userId, additionalPath); + uploadPath = imagePathService.generateUserUploadPath(imageType, userId, postId.toString()); } else { - uploadPath = imagePathService.generateSystemUploadPath(imageType, additionalPath); + uploadPath = imagePathService.generateSystemUploadPath(imageType, postId.toString()); } ImageUploadRequest request = ImageUploadRequest.builder() @@ -158,13 +273,13 @@ public ImageResponse uploadImageByType(ImageType imageType, org.springframework. return ImageResponse.from(savedImage, response.getFileUrl()); } - public List getImagesByType(ImageType imageType, Long userId, String additionalPath) { + public List getImagesByType(ImageType imageType, Long userId, Long postId) { // 타입에 따라 적절한 경로 생성 String uploadPath; if (imageType.name().startsWith("USER_")) { - uploadPath = imagePathService.generateUserUploadPath(imageType, userId, additionalPath); + uploadPath = imagePathService.generateUserUploadPath(imageType, userId, postId.toString()); } else { - uploadPath = imagePathService.generateSystemUploadPath(imageType, additionalPath); + uploadPath = imagePathService.generateSystemUploadPath(imageType, postId.toString()); } List images = imageRepository.findByS3KeyStartingWith(uploadPath); @@ -173,4 +288,70 @@ public List getImagesByType(ImageType imageType, Long userId, Str .collect(Collectors.toList()); } + + // temp URL을 permanent URL로 이동 처리 + private String migrateTempImageByUrl(String tempUrl, ImageType imageType, Long userId, Long postId) { + String tempS3Key = imageStorageService.extractS3KeyFromUrl(tempUrl); + + Image tempImage = imageRepository.findByS3Key(tempS3Key) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT_VALUE)); + + if (!userId.equals(tempImage.getUserId())) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + + if (isExpiredTempImage(tempImage)){ + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + + // permanent 경로 생성 + String permanentBasePath = imagePathService.generateUserUploadPath(imageType, userId, postId.toString()); + String newFileName = imageStorageService.generateUniqueFileName(tempImage.getOriginalFilename()); + String newS3Key = imageStorageService.generateS3Key(permanentBasePath, newFileName); + + imageStorageService.moveFile(tempS3Key, newS3Key); + + tempImage.updateS3Key(newS3Key); + tempImage.updateType(imageType); + tempImage.updatePostId(postId); // postId 설정 + + log.info("temp → permanent 이동 완료: imageId={}, postId={}", tempImage.getId(), postId); + + return imageStorageService.generatePublicUrl(newS3Key); + } + + + + + // 만료된 temp 이미지인지 확인 + private boolean isExpiredTempImage(Image image) { + return imagePathService.isTempImage(image.getS3Key()) && + image.getCreatedAt().isBefore(LocalDateTime.now().minusHours(TEMP_IMAGE_EXPIRY_HOURS)); + } + + // postId로 이미지 목록 조회 + public List findByTypeAndPostId(ImageType imageType, Long postId) { + return imageRepository.findByTypeAndPostId(imageType, postId); + } + + + // 이미지 메타데이터 추출 + private ImageMetadata extractImageMetadata(MultipartFile file) throws IOException { + BufferedImage bufferedImage = ImageIO.read(file.getInputStream()); + Long width = bufferedImage != null ? (long) bufferedImage.getWidth() : null; + Long height = bufferedImage != null ? (long) bufferedImage.getHeight() : null; + return new ImageMetadata(width, height); + } + + // 이미지 메타데이터 내부 클래스 + private static class ImageMetadata { + final Long width; + final Long height; + + ImageMetadata(Long width, Long height) { + this.width = width; + this.height = height; + } + } + } \ No newline at end of file diff --git a/src/main/java/com/divary/domain/image/service/ImageStorageService.java b/src/main/java/com/divary/domain/image/service/ImageStorageService.java index 22ed7d38..22fd003e 100644 --- a/src/main/java/com/divary/domain/image/service/ImageStorageService.java +++ b/src/main/java/com/divary/domain/image/service/ImageStorageService.java @@ -9,9 +9,13 @@ import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Object; import java.io.IOException; +import java.util.List; import java.util.UUID; @Slf4j @@ -39,7 +43,7 @@ public void uploadToS3(String s3Key, MultipartFile file) { .build(); s3Client.putObject(putObjectRequest, - RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); log.info("S3 업로드 완료: {}", s3Key); } catch (IOException e) { @@ -59,11 +63,95 @@ public void deleteFromS3(String s3Key) { } } + // S3에서 파일을 다른 경로로 이동 (복사 + 삭제) + public void moveFile(String sourceKey, String destinationKey) { + try { + // 1. 파일 복사 + s3Client.copyObject(builder -> builder + .sourceBucket(bucketName) + .sourceKey(sourceKey) + .destinationBucket(bucketName) + .destinationKey(destinationKey) + ); + log.info("S3 파일 복사 완료: {} -> {}", sourceKey, destinationKey); + + // 2. 복사 성공 확인 후 원본 파일 삭제 + if (verifyFileExists(destinationKey)) { + deleteFromS3(sourceKey); + log.info("S3 파일 이동 완료: {} -> {}", sourceKey, destinationKey); + } else { + log.error("파일 복사 검증 실패: {}", destinationKey); + throw new BusinessException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + + } catch (Exception e) { + log.error("S3 파일 이동 실패: {} -> {}", sourceKey, destinationKey, e); + throw new BusinessException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + } + + // 파일 존재 확인 + private boolean verifyFileExists(String s3Key) { + try { + s3Client.headObject(builder -> builder.bucket(bucketName).key(s3Key)); + return true; + } catch (Exception e) { + return false; + } + } + + // 다중 파일 이동 + public void moveFiles(List sourceKeys, List destinationKeys) { + if (sourceKeys.size() != destinationKeys.size()) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + + for (int i = 0; i < sourceKeys.size(); i++) { + moveFile(sourceKeys.get(i), destinationKeys.get(i)); + } + } + + // 배치로 파일 삭제 + public void deleteFiles(List s3Keys) { + for (String s3Key : s3Keys) { + try { + deleteFromS3(s3Key); + } catch (Exception e) { + log.error("배치 삭제 중 실패한 파일: {}", s3Key, e); + // 개별 파일 삭제 실패는 로그만 남기고 계속 진행 + } + } + } + // S3 키로부터 Public URL 생성 public String generatePublicUrl(String s3Key) { return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, s3Key); } + + // S3 URL에서 S3 키 추출 + public String extractS3KeyFromUrl(String imageUrl) { + if (imageUrl == null || imageUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCode.IMAGE_URL_INVALID_FORMAT); + } + + try { + String[] parts = imageUrl.split(".amazonaws.com/", 2); + if (parts.length == 2 && !parts[1].trim().isEmpty()) { + return parts[1]; + } + + log.error("S3 URL 형식 오류: {}", imageUrl); + throw new BusinessException(ErrorCode.IMAGE_URL_INVALID_FORMAT); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("S3 키 추출 중 예외 발생: url={}", imageUrl, e); + throw new BusinessException(ErrorCode.IMAGE_URL_INVALID_FORMAT); + } + } + // 고유한 파일명 생성 public String generateUniqueFileName(String originalFilename) { String uuid = UUID.randomUUID().toString(); @@ -77,6 +165,26 @@ public String generateS3Key(String uploadPath, String fileName) { return normalizedPath + fileName; } + // S3에서 temp 경로의 모든 파일 목록 조회 + public List listTempFiles(String tempPrefix) { + try { + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(tempPrefix) + .build(); + + ListObjectsV2Response response = s3Client.listObjectsV2(request); + + return response.contents().stream() + .map(S3Object::key) + .toList(); + + } catch (Exception e) { + log.error("S3 temp 파일 목록 조회 실패: prefix={}", tempPrefix, e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + private String getFileExtension(String filename) { if (filename == null || filename.lastIndexOf('.') == -1) { return ""; diff --git a/src/main/java/com/divary/domain/image/service/ImageValidationService.java b/src/main/java/com/divary/domain/image/service/ImageValidationService.java new file mode 100644 index 00000000..08682845 --- /dev/null +++ b/src/main/java/com/divary/domain/image/service/ImageValidationService.java @@ -0,0 +1,82 @@ +package com.divary.domain.image.service; + +import com.divary.domain.image.dto.request.ImageUploadRequest; +import com.divary.domain.image.enums.ImageType; +import com.divary.global.exception.BusinessException; +import com.divary.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageValidationService { + + private static final int MAX_FILES_PER_UPLOAD = 10; + private static final long MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB + + public void validateUploadRequest(ImageUploadRequest request) { + validateFile(request.getFile()); + validateUploadPath(request.getUploadPath()); + } + + public void validateMultipleFiles(List files) { + if (files == null || files.isEmpty()) { + throw new BusinessException(ErrorCode.REQUIRED_FIELD_MISSING); + } + + if (files.size() > MAX_FILES_PER_UPLOAD) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + + files.forEach(this::validateFile); + } + + public void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.REQUIRED_FIELD_MISSING); + } + + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new BusinessException(ErrorCode.IMAGE_SIZE_TOO_LARGE); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BusinessException(ErrorCode.IMAGE_FORMAT_NOT_SUPPORTED); + } + } + + public void validateUserImageType(ImageType imageType) { + if (!imageType.name().startsWith("USER_")) { + throw new IllegalArgumentException("USER 타입 이미지만 처리 가능합니다: " + imageType); + } + } + + public void validateSystemImageType(ImageType imageType) { + if (!imageType.name().startsWith("SYSTEM_")) { + throw new IllegalArgumentException("SYSTEM 타입 이미지만 처리 가능합니다: " + imageType); + } + } + + public void validateUserId(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + } + + private void validateUploadPath(String uploadPath) { + if (uploadPath == null || uploadPath.trim().isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + + // 경로 보안 검증 (../ 등 상위 디렉토리 접근 차단) + if (uploadPath.contains("..") || uploadPath.startsWith("/")) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/divary/domain/logbook/controller/LogBookController.java b/src/main/java/com/divary/domain/logbook/controller/LogBookController.java index 24283e57..cf76ab80 100644 --- a/src/main/java/com/divary/domain/logbook/controller/LogBookController.java +++ b/src/main/java/com/divary/domain/logbook/controller/LogBookController.java @@ -2,22 +2,23 @@ import com.divary.common.response.ApiResponse; import com.divary.domain.logbook.dto.request.LogBaseCreateRequestDTO; -import com.divary.domain.logbook.dto.request.LogDetailCreateRequestDTO; -import com.divary.domain.logbook.dto.response.LogBaseListResultDTO; -import com.divary.domain.logbook.dto.response.LogBaseCreateResultDTO; -import com.divary.domain.logbook.dto.response.LogBookDetailResultDTO; -import com.divary.domain.logbook.dto.response.LogDetailCreateResultDTO; +import com.divary.domain.logbook.dto.request.LogDetailPutRequestDTO; +import com.divary.domain.logbook.dto.request.LogNameUpdateRequestDTO; +import com.divary.domain.logbook.dto.response.*; import com.divary.domain.logbook.enums.SaveStatus; import com.divary.domain.logbook.service.LogBookService; +import com.divary.global.config.security.CustomUserPrincipal; +import com.divary.global.exception.ErrorCode; 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.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import com.divary.global.config.SwaggerConfig.ApiErrorExamples; +import com.divary.global.config.SwaggerConfig.ApiSuccessResponse; -import java.time.LocalDate; import java.util.List; @RestController @@ -31,24 +32,34 @@ public class LogBookController { @PostMapping @Operation(summary = "초기 로그 생성", description = "다이빙 로그를 생성합니다.") - public ApiResponse createLog - (@RequestBody @Valid LogBaseCreateRequestDTO createDTO) + @ApiSuccessResponse(dataType = LogBaseCreateResultDTO.class) + @ApiErrorExamples(value = {ErrorCode.AUTHENTICATION_REQUIRED}) + public ApiResponse createLogBase + (@RequestBody @Valid LogBaseCreateRequestDTO createDTO, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { - LogBaseCreateResultDTO responseDto = logBookService.createLogBase(createDTO); + Long userId = userPrincipal.getId(); + LogBaseCreateResultDTO responseDto = logBookService.createLogBase(createDTO, userId); return ApiResponse.success(responseDto); } @GetMapping + @ApiSuccessResponse(dataType = LogBaseListResultDTO.class) + @ApiErrorExamples(value = {ErrorCode.AUTHENTICATION_REQUIRED}) @Operation(summary = "로그 리스트 조회", description = "연도와 저장 상태에 따라 로그북 리스트를 조회합니다.") public ApiResponse> getLogsByYearAndStatus( @RequestParam int year, - @RequestParam(required = false) SaveStatus saveStatus) { + @RequestParam(required = false) SaveStatus saveStatus, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { - List result = logBookService.getLogBooksByYearAndStatus(year, saveStatus); + Long userId = userPrincipal.getId(); + List result = logBookService.getLogBooksByYearAndStatus(year, saveStatus, userId); return ApiResponse.success(result); } @GetMapping("/{logBaseInfoId}") + @ApiSuccessResponse(dataType = LogBookDetailResultDTO.class) + @ApiErrorExamples(value = {ErrorCode.LOG_NOT_FOUND, ErrorCode.LOG_BASE_NOT_FOUND, ErrorCode.AUTHENTICATION_REQUIRED}) @Operation(summary = "로그 상세조회", description = "특정 로그북의 상세 정보를 조회합니다.") public ApiResponse> getLogDetail (@PathVariable Long logBaseInfoId) { @@ -57,12 +68,54 @@ public ApiResponse> getLogsByYearAndStatus( } @PostMapping("/{logBaseInfoId}") - @Operation(summary = "세부 로그 생성", description = "특정 날짜에 해당하는 로그 세부 정보를 생성합니다.") - public ApiResponse createLogBook + @ApiSuccessResponse(dataType = LogDetailCreateResultDTO.class) + @ApiErrorExamples(value = {ErrorCode.LOG_ACCESS_DENIED, ErrorCode.LOG_BASE_NOT_FOUND,ErrorCode.LOG_LIMIT_EXCEEDED, ErrorCode.AUTHENTICATION_REQUIRED}) + @Operation(summary = "비어있는 세부 로그북 생성", description = "특정 날짜에 해당하는, 내용 없는 기본 로그북을 생성합니다.") + public ApiResponse createLogDetail (@PathVariable Long logBaseInfoId, - @RequestBody @Valid LogDetailCreateRequestDTO dto) { - LogDetailCreateResultDTO result = logBookService.createLogDetail(dto, logBaseInfoId); + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId = userPrincipal.getId(); + LogDetailCreateResultDTO result = logBookService.createLogDetail(logBaseInfoId, userId); return ApiResponse.success(result); } + @DeleteMapping("/{logBaseInfoId}") + @ApiSuccessResponse(dataType = void.class) + @ApiErrorExamples(value = {ErrorCode.LOG_ACCESS_DENIED, ErrorCode.LOG_BASE_NOT_FOUND,ErrorCode.LOG_NOT_FOUND, ErrorCode.AUTHENTICATION_REQUIRED}) + @Operation(summary = "로그 삭제", description = "지정한 다이빙 로그를 삭제합니다.") + public ApiResponse deleteLogBase + (@PathVariable @Valid Long logBaseInfoId, + @AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId= userPrincipal.getId(); + logBookService.deleteLog(logBaseInfoId, userId); + return ApiResponse.success(null); + } + + @PutMapping("/{logBookId}") + @Operation(summary = "로그 전체 수정", description = "다이빙 로그 세부 정보를 전체 수정합니다.") + @ApiSuccessResponse(dataType = LogDetailPutResultDTO.class) + @ApiErrorExamples(value = {ErrorCode.LOG_ACCESS_DENIED, ErrorCode.LOG_NOT_FOUND, ErrorCode.LOG_BASE_NOT_FOUND, ErrorCode.AUTHENTICATION_REQUIRED}) + public ApiResponse updateLogDetail( + @AuthenticationPrincipal CustomUserPrincipal userPrincipal, + @PathVariable Long logBookId, + @RequestBody @Valid LogDetailPutRequestDTO dto) { + + LogDetailPutResultDTO result = logBookService.updateLogBook(userPrincipal.getId(), logBookId, dto); + return ApiResponse.success(result); + } + + @PatchMapping("/{logBaseInfoId}") + @Operation(summary = "로그북 이름 변경", description = "로그북의 이름을 변경합니다.") + @ApiSuccessResponse(dataType = Void.class) + @ApiErrorExamples(value = {ErrorCode.LOG_ACCESS_DENIED, ErrorCode.LOG_BASE_NOT_FOUND, ErrorCode.AUTHENTICATION_REQUIRED}) + public ApiResponse updateLogBaseName( + @AuthenticationPrincipal CustomUserPrincipal userPrincipal, + @PathVariable Long logBaseInfoId, + @RequestBody @Valid LogNameUpdateRequestDTO dto){ + + Long userId = userPrincipal.getId(); + logBookService.updateLogName(logBaseInfoId, userId, dto.getName()); + return ApiResponse.success(null); + } + } diff --git a/src/main/java/com/divary/domain/logbook/dto/request/CompanionRequestDTO.java b/src/main/java/com/divary/domain/logbook/dto/request/CompanionRequestDTO.java index 4c01c2e5..769d41a3 100644 --- a/src/main/java/com/divary/domain/logbook/dto/request/CompanionRequestDTO.java +++ b/src/main/java/com/divary/domain/logbook/dto/request/CompanionRequestDTO.java @@ -13,7 +13,7 @@ @AllArgsConstructor public class CompanionRequestDTO { @Schema(description = "동반자 이름", example = "김버디") - private String companion; + private String name; @Schema(description = "동반자 타입", example = "LEADER") private CompanionType type; diff --git a/src/main/java/com/divary/domain/logbook/dto/request/LogDetailCreateRequestDTO.java b/src/main/java/com/divary/domain/logbook/dto/request/LogDetailPutRequestDTO.java similarity index 94% rename from src/main/java/com/divary/domain/logbook/dto/request/LogDetailCreateRequestDTO.java rename to src/main/java/com/divary/domain/logbook/dto/request/LogDetailPutRequestDTO.java index 2f317623..00392557 100644 --- a/src/main/java/com/divary/domain/logbook/dto/request/LogDetailCreateRequestDTO.java +++ b/src/main/java/com/divary/domain/logbook/dto/request/LogDetailPutRequestDTO.java @@ -2,7 +2,6 @@ import com.divary.domain.logbook.enums.*; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -15,7 +14,10 @@ @Getter @NoArgsConstructor @AllArgsConstructor -public class LogDetailCreateRequestDTO { +public class LogDetailPutRequestDTO { + + @Schema(description = "로그북베이스정보 id") + private Long logBaseInfoId; @Schema(description = "다이빙 날짜", example = "2025-07-25") private LocalDate date; @@ -71,7 +73,7 @@ public class LogDetailCreateRequestDTO { @Schema(description = "체감 온도", example = "COLD") private PerceiveTemp perceivedTemp; - @Schema(description = "시야 거리", example = "3M") + @Schema(description = "시야", example = "GOOD") private Sight sight; @Schema(description = "총 다이빙 시간(분 단위)", example = "45") diff --git a/src/main/java/com/divary/domain/logbook/dto/request/LogNameUpdateRequestDTO.java b/src/main/java/com/divary/domain/logbook/dto/request/LogNameUpdateRequestDTO.java new file mode 100644 index 00000000..a9bb720a --- /dev/null +++ b/src/main/java/com/divary/domain/logbook/dto/request/LogNameUpdateRequestDTO.java @@ -0,0 +1,18 @@ +package com.divary.domain.logbook.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LogNameUpdateRequestDTO { + @Schema(description = "수정할 로그 이름", example = "해양일지") + @NotBlank + private String name; +} diff --git a/src/main/java/com/divary/domain/logbook/dto/response/LogBaseListResultDTO.java b/src/main/java/com/divary/domain/logbook/dto/response/LogBaseListResultDTO.java index e9b7af7a..d46bbded 100644 --- a/src/main/java/com/divary/domain/logbook/dto/response/LogBaseListResultDTO.java +++ b/src/main/java/com/divary/domain/logbook/dto/response/LogBaseListResultDTO.java @@ -1,6 +1,7 @@ package com.divary.domain.logbook.dto.response; import com.divary.domain.logbook.enums.IconType; +import com.divary.domain.logbook.enums.SaveStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,4 +28,7 @@ public class LogBaseListResultDTO { @Schema(description = "베이스로그 id") private Long LogBaseInfoId; + @Schema(description = "로그북 저장 상태", example = "TEMP") + private SaveStatus saveStatus; + } diff --git a/src/main/java/com/divary/domain/logbook/dto/response/LogBookDetailResultDTO.java b/src/main/java/com/divary/domain/logbook/dto/response/LogBookDetailResultDTO.java index 564a6d12..d9b778f9 100644 --- a/src/main/java/com/divary/domain/logbook/dto/response/LogBookDetailResultDTO.java +++ b/src/main/java/com/divary/domain/logbook/dto/response/LogBookDetailResultDTO.java @@ -2,6 +2,7 @@ import com.divary.domain.logbook.entity.Companion; import com.divary.domain.logbook.entity.LogBook; +import com.divary.domain.logbook.enums.SaveStatus; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -15,10 +16,12 @@ @AllArgsConstructor public class LogBookDetailResultDTO { + private Long LogBookId; private String name; private String icon; private LocalDate date; + private SaveStatus saveStatus; private int accumulation; private String place; @@ -53,8 +56,10 @@ public class LogBookDetailResultDTO { public static LogBookDetailResultDTO from(LogBook logBook, List companions) { return LogBookDetailResultDTO.builder() + .LogBookId(logBook.getId()) .name(logBook.getLogBaseInfo().getName()) .icon(logBook.getLogBaseInfo().getIconType().name()) + .saveStatus(logBook.getSaveStatus()) .accumulation(logBook.getAccumulation()) .date(logBook.getLogBaseInfo().getDate()) .place(logBook.getPlace()) diff --git a/src/main/java/com/divary/domain/logbook/dto/response/LogDetailPutResultDTO.java b/src/main/java/com/divary/domain/logbook/dto/response/LogDetailPutResultDTO.java new file mode 100644 index 00000000..ba3683cf --- /dev/null +++ b/src/main/java/com/divary/domain/logbook/dto/response/LogDetailPutResultDTO.java @@ -0,0 +1,18 @@ +package com.divary.domain.logbook.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LogDetailPutResultDTO { + + private Long logBookId; + + private String message; +} + diff --git a/src/main/java/com/divary/domain/logbook/entity/Companion.java b/src/main/java/com/divary/domain/logbook/entity/Companion.java index 21bfd1a7..57fb31e1 100644 --- a/src/main/java/com/divary/domain/logbook/entity/Companion.java +++ b/src/main/java/com/divary/domain/logbook/entity/Companion.java @@ -6,6 +6,9 @@ import lombok.*; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.List; + @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -15,7 +18,7 @@ public class Companion extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "logbook_id") private LogBook logBook; diff --git a/src/main/java/com/divary/domain/logbook/entity/LogBaseInfo.java b/src/main/java/com/divary/domain/logbook/entity/LogBaseInfo.java index 57d07d2b..74c70e6f 100644 --- a/src/main/java/com/divary/domain/logbook/entity/LogBaseInfo.java +++ b/src/main/java/com/divary/domain/logbook/entity/LogBaseInfo.java @@ -9,6 +9,8 @@ import lombok.*; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; @Getter @Schema(description = "다이빙 로그 기본정보") @@ -19,11 +21,15 @@ @Setter public class LogBaseInfo extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @Schema(description = "유저 id", example = "1L") private Member member; + @OneToMany(mappedBy = "logBaseInfo", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default + private List logBooks = new ArrayList<>(); + @Column(name = "name", nullable = false, length = 40) @Schema(description = "로그 제목", example = "고래 원정") private String name; @@ -40,5 +46,10 @@ public class LogBaseInfo extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "save_status",nullable = false) @Schema(description = "저장 상태", example = "COMPLETE") + @Builder.Default private SaveStatus saveStatus = SaveStatus.COMPLETE; + + public void updateName(String name) { + this.name = name; + } } diff --git a/src/main/java/com/divary/domain/logbook/entity/LogBook.java b/src/main/java/com/divary/domain/logbook/entity/LogBook.java index c41bde54..ce1c4e51 100644 --- a/src/main/java/com/divary/domain/logbook/entity/LogBook.java +++ b/src/main/java/com/divary/domain/logbook/entity/LogBook.java @@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; @Getter @Schema(description = "다이빙 로그 세부정보") @@ -15,13 +17,22 @@ @AllArgsConstructor @Builder @Entity +@Setter public class LogBook extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "log_base_info_id") @Schema(description = "로그북의 기본정보 외래키", example = "1") private LogBaseInfo logBaseInfo; + @OneToMany(mappedBy = "logBook", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Schema(description = "동행자 리스트") + private List companions = new ArrayList<>(); + + @Column(name = "save_status") + @Schema(description = "각 로그북의 저장 상태", example = "TEMP") + private SaveStatus saveStatus; + @Column(name = "accumulation",nullable = false) @Schema(description = "누적 횟수", example = "3") private int accumulation; @@ -35,7 +46,7 @@ public class LogBook extends BaseEntity { private String divePoint; @Enumerated(EnumType.STRING) - @Column(name = "dive_type") + @Column(name = "dive_method") @Schema(description = "다이빙 방식", example = "보트") private DiveMethod diveMethod; diff --git a/src/main/java/com/divary/domain/logbook/repository/CompanionRepository.java b/src/main/java/com/divary/domain/logbook/repository/CompanionRepository.java index 78e50feb..3cf6ae92 100644 --- a/src/main/java/com/divary/domain/logbook/repository/CompanionRepository.java +++ b/src/main/java/com/divary/domain/logbook/repository/CompanionRepository.java @@ -8,5 +8,8 @@ public interface CompanionRepository extends JpaRepository { List findByLogBook(LogBook logBook); + + void deleteByLogBook(LogBook logBook); + } diff --git a/src/main/java/com/divary/domain/logbook/repository/LogBaseInfoRepository.java b/src/main/java/com/divary/domain/logbook/repository/LogBaseInfoRepository.java index 90985e6f..fa3afc82 100644 --- a/src/main/java/com/divary/domain/logbook/repository/LogBaseInfoRepository.java +++ b/src/main/java/com/divary/domain/logbook/repository/LogBaseInfoRepository.java @@ -12,10 +12,12 @@ import java.util.Optional; public interface LogBaseInfoRepository extends JpaRepository { - @Query("SELECT l FROM LogBaseInfo l WHERE YEAR(l.date) = :year AND l.saveStatus = :status ORDER BY l.date DESC") - List findByYearAndStatus(@Param("year") int year, @Param("status") SaveStatus status); + @Query("SELECT l FROM LogBaseInfo l WHERE YEAR(l.date) = :year AND l.saveStatus = :status AND l.member = :member ORDER BY l.date DESC") + List findByYearAndStatusAndMember(@Param("year") int year, @Param("status") SaveStatus status, @Param("member") Member member); - @Query("SELECT l FROM LogBaseInfo l WHERE YEAR(l.date) = :year ORDER BY l.date DESC") - List findByYear(@Param("year") int year); + @Query("SELECT l FROM LogBaseInfo l WHERE YEAR(l.date) = :year AND l.member = :member ORDER BY l.date DESC") + List findByYearAndMember(@Param("year") int year, @Param("member") Member member); + + Optional findByIdAndMemberId(Long id, Long memberId); } diff --git a/src/main/java/com/divary/domain/logbook/repository/LogBookRepository.java b/src/main/java/com/divary/domain/logbook/repository/LogBookRepository.java index 4052af9d..ff59b438 100644 --- a/src/main/java/com/divary/domain/logbook/repository/LogBookRepository.java +++ b/src/main/java/com/divary/domain/logbook/repository/LogBookRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface LogBookRepository extends JpaRepository { @@ -18,4 +19,8 @@ public interface LogBookRepository extends JpaRepository { int countByLogBaseInfoMember(Member member); int countByLogBaseInfo(LogBaseInfo logBaseInfo); + + Optional findByIdAndLogBaseInfoMemberId(Long logBookId, Long memberId); + + } diff --git a/src/main/java/com/divary/domain/logbook/service/LogBookService.java b/src/main/java/com/divary/domain/logbook/service/LogBookService.java index a99faecf..f3d8ed04 100644 --- a/src/main/java/com/divary/domain/logbook/service/LogBookService.java +++ b/src/main/java/com/divary/domain/logbook/service/LogBookService.java @@ -4,11 +4,8 @@ import com.divary.domain.Member.service.MemberServiceImpl; import com.divary.domain.logbook.dto.request.CompanionRequestDTO; import com.divary.domain.logbook.dto.request.LogBaseCreateRequestDTO; -import com.divary.domain.logbook.dto.request.LogDetailCreateRequestDTO; -import com.divary.domain.logbook.dto.response.LogBaseListResultDTO; -import com.divary.domain.logbook.dto.response.LogBaseCreateResultDTO; -import com.divary.domain.logbook.dto.response.LogBookDetailResultDTO; -import com.divary.domain.logbook.dto.response.LogDetailCreateResultDTO; +import com.divary.domain.logbook.dto.request.LogDetailPutRequestDTO; +import com.divary.domain.logbook.dto.response.*; import com.divary.domain.logbook.entity.Companion; import com.divary.domain.logbook.entity.LogBaseInfo; import com.divary.domain.logbook.entity.LogBook; @@ -22,8 +19,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; import java.util.List; import java.util.stream.Collectors; @@ -37,9 +32,10 @@ public class LogBookService { private final MemberServiceImpl memberService; @Transactional - public LogBaseCreateResultDTO createLogBase(@Valid LogBaseCreateRequestDTO request) { + public LogBaseCreateResultDTO createLogBase + (@Valid LogBaseCreateRequestDTO request, Long userId) { - Member member = memberService.findById(1L);//임시로 데이터 넣음 + Member member = memberService.findById(userId); LogBaseInfo logBaseInfo = LogBaseInfo.builder() .iconType(request.getIconType()) @@ -64,15 +60,17 @@ public LogBaseCreateResultDTO createLogBase(@Valid LogBaseCreateRequestDTO reque } @Transactional - public List getLogBooksByYearAndStatus(int year, SaveStatus status) { + public List getLogBooksByYearAndStatus(int year, SaveStatus status, Long userId) { List logBaseInfoList; + Member member = memberService.findById(userId); + if (status == null) { // 쿼리스트링 없을 경우 전체 조회 - logBaseInfoList = logBaseInfoRepository.findByYear(year); + logBaseInfoList = logBaseInfoRepository.findByYearAndMember(year,member); } else { - logBaseInfoList = logBaseInfoRepository.findByYearAndStatus(year,status); + logBaseInfoList = logBaseInfoRepository.findByYearAndStatusAndMember(year,status,member); } return logBaseInfoList.stream() @@ -80,6 +78,7 @@ public List getLogBooksByYearAndStatus(int year, SaveStatu .name(logBaseInfo.getName()) .date(logBaseInfo.getDate()) .iconType(logBaseInfo.getIconType()) + .saveStatus(logBaseInfo.getSaveStatus()) .LogBaseInfoId(logBaseInfo.getId()) .build()) .collect(Collectors.toList()); @@ -108,9 +107,9 @@ public List getLogDetail(Long logBaseInfoId) { } @Transactional - public LogDetailCreateResultDTO createLogDetail(LogDetailCreateRequestDTO dto, Long logBaseInfoId) { + public LogDetailCreateResultDTO createLogDetail(Long logBaseInfoId, Long userId) { - LogBaseInfo base = logBaseInfoRepository.findById(logBaseInfoId) + LogBaseInfo base = logBaseInfoRepository.findByIdAndMemberId(logBaseInfoId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.LOG_BASE_NOT_FOUND)); // 연결된 기존의 로그북 개수 확인 @@ -118,63 +117,106 @@ public LogDetailCreateResultDTO createLogDetail(LogDetailCreateRequestDTO dto, L throw new BusinessException(ErrorCode.LOG_LIMIT_EXCEEDED); }//하루 최대 3개 넘으면 에러 던지기 - if (dto.getSaveStatus() == SaveStatus.TEMP){ - base.setSaveStatus(SaveStatus.TEMP); - logBaseInfoRepository.save(base); - }//로그 세부내용이 임시저장 상태면 로그베이스 저장상태를 임시저장으로 변환 - - if (dto.getDate() != base.getDate()){ - base.setDate(dto.getDate()); - logBaseInfoRepository.save(base); - }//처음 로그북 추가할 때의 날짜를 다시 변경하는 경우, 로그베이스의 날짜까지 다시 수정 - - Member member = memberService.findById(1L);//임시로 데이터 넣음 + Member member = memberService.findById(userId); int accumulation = logBookRepository.countByLogBaseInfoMember(member)+1; //누적횟수 계산 LogBook logBook = LogBook.builder() .logBaseInfo(base) .accumulation(accumulation) - .place(dto.getPlace()) - .divePoint(dto.getDivePoint()) - .diveMethod(dto.getDiveMethod()) - .divePurpose(dto.getDivePurpose()) - .suitType(dto.getSuitType()) - .equipment(dto.getEquipment()) - .weight(dto.getWeight()) - .perceivedWeight(dto.getPerceivedWeight()) - .weatherType(dto.getWeather()) - .wind(dto.getWind()) - .tide(dto.getTide()) - .wave(dto.getWave()) - .temperature(dto.getTemperature()) - .waterTemperature(dto.getWaterTemperature()) - .perceivedTemp(dto.getPerceivedTemp()) - .sight(dto.getSight()) - .diveTime(dto.getDiveTime()) - .maxDepth(dto.getMaxDepth()) - .avgDepth(dto.getAvgDepth()) - .decompressDepth(dto.getDecompressDepth()) - .decompressTime(dto.getDecompressTime()) - .startPressure(dto.getStartPressure()) - .finishPressure(dto.getFinishPressure()) - .consumption(dto.getConsumption()) .build(); logBookRepository.save(logBook); + return new LogDetailCreateResultDTO(logBook.getId(),"세부 로그북 생성 완료"); + } + + @Transactional + public void deleteLog(Long logBaseId,Long userId) { + + LogBaseInfo logBaseInfo = logBaseInfoRepository.findByIdAndMemberId(logBaseId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.LOG_BASE_NOT_FOUND)); + + + if (logBaseInfo.getLogBooks() == null || logBaseInfo.getLogBooks().isEmpty()) { + throw new BusinessException(ErrorCode.LOG_NOT_FOUND); + } + + logBaseInfoRepository.delete(logBaseInfo); + } + + @Transactional + public LogDetailPutResultDTO updateLogBook(Long userId, Long logBookId, LogDetailPutRequestDTO dto) { + + LogBook logBook = logBookRepository.findByIdAndLogBaseInfoMemberId(logBookId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.LOG_NOT_FOUND)); + + + // 모든 필드 덮어쓰기 (null도 그대로 반영) + logBook.setPlace(dto.getPlace()); + logBook.setSaveStatus(dto.getSaveStatus()); + logBook.setDivePoint(dto.getDivePoint()); + logBook.setDiveMethod(dto.getDiveMethod()); + logBook.setDivePurpose(dto.getDivePurpose()); + logBook.setSuitType(dto.getSuitType()); + logBook.setEquipment(dto.getEquipment()); + logBook.setWeight(dto.getWeight()); + logBook.setPerceivedWeight(dto.getPerceivedWeight()); + logBook.setDiveTime(dto.getDiveTime()); + logBook.setMaxDepth(dto.getMaxDepth()); + logBook.setAvgDepth(dto.getAvgDepth()); + logBook.setDecompressDepth(dto.getDecompressDepth()); + logBook.setDecompressTime(dto.getDecompressTime()); + logBook.setStartPressure(dto.getStartPressure()); + logBook.setFinishPressure(dto.getFinishPressure()); + logBook.setConsumption(dto.getConsumption()); + logBook.setWeatherType(dto.getWeather()); + logBook.setWind(dto.getWind()); + logBook.setTide(dto.getTide()); + logBook.setWave(dto.getWave()); + logBook.setTemperature(dto.getTemperature()); + logBook.setWaterTemperature(dto.getWaterTemperature()); + logBook.setPerceivedTemp(dto.getPerceivedTemp()); + logBook.setSight(dto.getSight()); + + + LogBaseInfo base = logBaseInfoRepository.findById(dto.getLogBaseInfoId()) + .orElseThrow(() -> new BusinessException(ErrorCode.LOG_BASE_NOT_FOUND)); + + if (dto.getSaveStatus() == SaveStatus.TEMP){ + base.setSaveStatus(SaveStatus.TEMP); + logBaseInfoRepository.save(base); + }//로그 세부내용이 임시저장 상태면 로그베이스 저장상태를 임시저장으로 변환 + + if (dto.getDate() != base.getDate()){ + base.setDate(dto.getDate()); + logBaseInfoRepository.save(base); + }//처음 로그북 추가할 때의 날짜를 다시 변경하는 경우, 로그베이스의 날짜까지 다시 수정 + + // Companion 덮어쓰기 (기존 삭제 후 다시 저장) + companionRepository.deleteByLogBook(logBook); if (dto.getCompanions() != null) { for (CompanionRequestDTO c : dto.getCompanions()) { Companion companion = Companion.builder() - .name(c.getCompanion()) - .type(c.getType()) .logBook(logBook) + .name(c.getName()) + .type(c.getType()) .build(); companionRepository.save(companion); } } - return new LogDetailCreateResultDTO(logBook.getId(),"로그 세부내용 저장 완료"); + return new LogDetailPutResultDTO(logBook.getId(), "로그북이 수정되었습니다."); + } + + @Transactional + public void updateLogName(Long logBaseInfoId, Long userId, String name){ + + LogBaseInfo logBaseInfo = logBaseInfoRepository.findByIdAndMemberId(logBaseInfoId, userId) + .orElseThrow(()->new BusinessException(ErrorCode.LOG_BASE_NOT_FOUND)); + + logBaseInfo.updateName(name); + } diff --git a/src/main/java/com/divary/domain/system/controller/SystemController.java b/src/main/java/com/divary/domain/system/controller/SystemController.java index 8f3e6322..2d937145 100644 --- a/src/main/java/com/divary/domain/system/controller/SystemController.java +++ b/src/main/java/com/divary/domain/system/controller/SystemController.java @@ -1,7 +1,14 @@ package com.divary.domain.system.controller; import com.divary.common.response.ApiResponse; +import com.divary.domain.Member.entity.Member; +import com.divary.domain.Member.enums.Role; +import com.divary.domain.Member.repository.MemberRepository; +import com.divary.common.enums.SocialType; +import com.divary.domain.image.enums.ImageType; +import com.divary.domain.image.service.ImageService; import com.divary.global.config.SwaggerConfig.ApiErrorExamples; +import com.divary.global.config.security.jwt.JwtTokenProvider; import com.divary.global.exception.ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,17 +17,26 @@ import java.sql.SQLException; import javax.sql.DataSource; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.web.bind.annotation.*; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @Tag(name = "System", description = "시스템 관리 및 모니터링") +@Slf4j @RestController -@RequestMapping("/api/system") +@RequestMapping("/system") @RequiredArgsConstructor public class SystemController { private final DataSource dataSource; + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final ImageService imageService; @Operation(summary = "헬스 체크", description = "서비스 및 DB 상태를 확인합니다.") @ApiErrorExamples({ @@ -75,4 +91,142 @@ public ApiResponse validationTest( return ApiResponse.success("유효성 검증 통과", value); } + // @Profile("dev") 추후 dev 환경에서만 사용하도록 수정 + @Operation(summary = "테스트 유저 생성", description = "개발 환경용 테스트 유저를 생성합니다. JWT 인증 테스트를 위해 필요합니다.") + @PostMapping("/test-user") + public ApiResponse createTestUser(@RequestParam(defaultValue = "test@divary.com") String email) { + if (memberRepository.findByEmail(email).isEmpty()) { + Member testUser = Member.builder() + .email(email) + .socialType(SocialType.GOOGLE) + .role(Role.USER) + .build(); + memberRepository.save(testUser); + return ApiResponse.success("테스트 유저 생성됨: " + email + " (ID: " + testUser.getId() + ")"); + } + Member existingUser = memberRepository.findByEmail(email).get(); + return ApiResponse.success("테스트 유저 이미 존재: " + email + " (ID: " + existingUser.getId() + ")"); + } + + // @Profile("dev") 추후 dev 환경에서만 사용하도록 수정 + @Operation(summary = "테스트 JWT 토큰 발급", description = "개발 환경용 JWT 토큰을 발급합니다.") + @PostMapping("/test-token") + public ApiResponse generateTestToken(@RequestParam(defaultValue = "test@divary.com") String email) { + Authentication auth = new UsernamePasswordAuthenticationToken( + email, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + String token = jwtTokenProvider.generateToken(auth); + return ApiResponse.success(token); + } + + @PostMapping("/test/image-conversion") + public ImageConversionTestResponse testImageConversion( + @RequestParam("userId") Long userId, + @RequestParam("boardId") Long boardId, + @RequestParam("tempUrl") String tempUrl) { + + log.info("이미지 경로 변환 테스트 - 사용자: {}, 게시판: {}, temp URL: {}", userId, boardId, tempUrl); + + // temp URL이 포함된 테스트 컨텐츠 생성 + String testContent = String.format("게시글 내용입니다.\n\n%s\n\n이미지가 포함된 내용입니다.", tempUrl); + + log.info("원본 컨텐츠: {}", testContent); + + // 게시글 타입으로 이미지 경로 변환 (temp -> test_post) + String processedContent = imageService.processContentAndMigrateImages( + testContent, + ImageType.USER_TEST_POST, + userId, + boardId + ); + + log.info("변환된 컨텐츠: {}", processedContent); + + boolean isConverted = !testContent.equals(processedContent); + + return ImageConversionTestResponse.builder() + .success(true) + .message(isConverted ? "이미지 경로가 성공적으로 변환되었습니다." : "변환할 temp 이미지가 없거나 변환에 실패했습니다.") + .userId(userId) + .boardId(boardId) + .originalTempUrl(tempUrl) + .originalContent(testContent) + .processedContent(processedContent) + .isConverted(isConverted) + .build(); + } + + @Operation(summary = "게시글 수정 시 이미지 정리 테스트", description = "게시글 수정 시 삭제된 이미지들이 정리되는지 테스트합니다.") + @PostMapping("/test/post-update-cleanup") + public PostUpdateCleanupTestResponse testPostUpdateCleanup( + @RequestParam("postId") Long postId, + @RequestParam("newContent") String newContent) { + + log.info("게시글 수정 시 이미지 정리 테스트 시각 게시글 ID: {}", postId); + log.info("새 컨텐츠: {}", newContent); + + try { + // 수정 전 해당 게시글의 이미지 목록 조회 + var beforeImages = imageService.findByTypeAndPostId(ImageType.USER_TEST_POST, postId); + log.info("수정 전 이미지 개수: {}", beforeImages.size()); + + // 게시글 수정 시 삭제된 이미지 정리 실행 + imageService.processDeletedImagesAfterPostUpdate(ImageType.USER_TEST_POST, postId, newContent); + + // 수정 후 해당 게시글의 이미지 목록 조회 + var afterImages = imageService.findByTypeAndPostId(ImageType.USER_TEST_POST, postId); + log.info("수정 후 이미지 개수: {}", afterImages.size()); + + int deletedCount = beforeImages.size() - afterImages.size(); + + return PostUpdateCleanupTestResponse.builder() + .success(true) + .message(String.format("이미지 정리 완료. %d개 이미지가 삭제되었습니다.", deletedCount)) + .postId(postId) + .newContent(newContent) + .beforeImageCount(beforeImages.size()) + .afterImageCount(afterImages.size()) + .deletedImageCount(deletedCount) + .build(); + + } catch (Exception e) { + log.error("게시글 수정 시 이미지 정리 테스트 실패", e); + return PostUpdateCleanupTestResponse.builder() + .success(false) + .message("테스트 실패: " + e.getMessage()) + .postId(postId) + .newContent(newContent) + .beforeImageCount(0) + .afterImageCount(0) + .deletedImageCount(0) + .build(); + } + } + // 테스트용 응답 DTO + @lombok.Builder + @lombok.Getter + public static class ImageConversionTestResponse { + private boolean success; + private String message; + private Long userId; + private Long boardId; + private String originalTempUrl; + private String originalContent; + private String processedContent; + private boolean isConverted; + } + + @lombok.Builder + @lombok.Getter + public static class PostUpdateCleanupTestResponse { + private boolean success; + private String message; + private Long postId; + private String newContent; + private int beforeImageCount; + private int afterImageCount; + private int deletedImageCount; + } } \ No newline at end of file diff --git a/src/main/java/com/divary/global/config/SwaggerConfig.java b/src/main/java/com/divary/global/config/SwaggerConfig.java index 0c8a37a7..dc10b557 100644 --- a/src/main/java/com/divary/global/config/SwaggerConfig.java +++ b/src/main/java/com/divary/global/config/SwaggerConfig.java @@ -11,12 +11,17 @@ import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; import lombok.Builder; import lombok.Getter; import org.springdoc.core.customizers.OperationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -38,43 +43,79 @@ ) public class SwaggerConfig { + @Autowired + private ApplicationContext applicationContext; + @Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("Divary API") .description("다이빙 서포트 앱 Divary의 REST API 문서") - .version("v1.0.0")); + .version("v1.0.0")) + .addSecurityItem(new SecurityRequirement().addList("JWT")); } @Bean public OperationCustomizer operationCustomizer() { return (Operation operation, HandlerMethod handlerMethod) -> { + // 실제 API 경로 정보 추출 + String actualPath = extractActualPath(handlerMethod); // 단일 에러 코드 어노테이션 처리 ApiErrorExample apiErrorExample = handlerMethod.getMethodAnnotation(ApiErrorExample.class); if (apiErrorExample != null) { - generateErrorCodeResponseExample(operation, new ErrorCode[]{apiErrorExample.value()}); + generateErrorCodeResponseExample(operation, new ErrorCode[]{apiErrorExample.value()}, actualPath); } // 복수 에러 코드 어노테이션 처리 ApiErrorExamples apiErrorExamples = handlerMethod.getMethodAnnotation(ApiErrorExamples.class); if (apiErrorExamples != null) { - generateErrorCodeResponseExample(operation, apiErrorExamples.value()); + generateErrorCodeResponseExample(operation, apiErrorExamples.value(), actualPath); } return operation; }; } + // HandlerMethod에서 실제 API 경로 추출 + private String extractActualPath(HandlerMethod handlerMethod) { + try { + RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class); + Map handlerMethods = mapping.getHandlerMethods(); + + for (Map.Entry entry : handlerMethods.entrySet()) { + if (entry.getValue().equals(handlerMethod)) { + RequestMappingInfo info = entry.getKey(); + + // PathPatternsCondition 확인 + var pathPatternsCondition = info.getPathPatternsCondition(); + if (pathPatternsCondition != null && !pathPatternsCondition.getPatterns().isEmpty()) { + return pathPatternsCondition.getPatterns().iterator().next().getPatternString(); + } + + // PatternsCondition 확인 + var patternsCondition = info.getPatternsCondition(); + if (patternsCondition != null && !patternsCondition.getPatterns().isEmpty()) { + return patternsCondition.getPatterns().iterator().next(); + } + } + } + } catch (Exception e) { + // 경로 추출 실패시 기본값 사용 + } + + return "/api/example"; + } + // 에러 코드들을 기반으로 Swagger 응답 예제를 생성 - private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes) { + private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes, String actualPath) { ApiResponses responses = operation.getResponses(); // HTTP 상태 코드별로 에러 코드들을 그룹화 Map> statusWithExampleHolders = Arrays.stream(errorCodes) .map(errorCode -> ExampleHolder.builder() - .example(createErrorExample(errorCode)) + .example(createErrorExample(errorCode, actualPath)) .name(errorCode.name()) .httpStatus(errorCode.getStatus().value()) .build()) @@ -85,14 +126,14 @@ private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] e } // ErrorCode를 기반으로 Example 객체 생성 - private Example createErrorExample(ErrorCode errorCode) { + private Example createErrorExample(ErrorCode errorCode, String actualPath) { // 에러 응답 객체 생성 Map errorResponse = new LinkedHashMap<>(); errorResponse.put("timestamp", "2025-06-30T12:00:00.000000"); errorResponse.put("status", errorCode.getStatus().value()); errorResponse.put("code", errorCode.getCode()); errorResponse.put("message", errorCode.getMessage()); - errorResponse.put("path", "/api/example"); + errorResponse.put("path", actualPath); Example example = new Example(); example.description(errorCode.getMessage()); diff --git a/src/main/java/com/divary/global/config/security/CustomUserDetailsService.java b/src/main/java/com/divary/global/config/security/CustomUserDetailsService.java new file mode 100644 index 00000000..7b7776d9 --- /dev/null +++ b/src/main/java/com/divary/global/config/security/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.divary.global.config.security; + +import com.divary.domain.Member.entity.Member; +import com.divary.domain.Member.repository.MemberRepository; +import com.divary.global.exception.BusinessException; +import com.divary.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + return new CustomUserPrincipal(member); + } +} \ No newline at end of file diff --git a/src/main/java/com/divary/global/config/security/CustomUserPrincipal.java b/src/main/java/com/divary/global/config/security/CustomUserPrincipal.java new file mode 100644 index 00000000..a90a9332 --- /dev/null +++ b/src/main/java/com/divary/global/config/security/CustomUserPrincipal.java @@ -0,0 +1,70 @@ +package com.divary.global.config.security; + +import com.divary.domain.Member.entity.Member; +import com.divary.domain.Member.enums.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class CustomUserPrincipal implements UserDetails { + + private final Long id; + private final String email; + private final Role role; + private final Collection authorities; + + public CustomUserPrincipal(Member member) { + this.id = member.getId(); + this.email = member.getEmail(); + this.role = member.getRole(); + this.authorities = Collections.singleton( + new SimpleGrantedAuthority("ROLE_" + member.getRole().name()) + ); + } + + // UserDetails 구현 메서드들 + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return ""; // OAuth2만 제공하고 있으므로 비밀번호 X + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + /* + * 현재 시스템에서 계정 만료, 잠금, 인증 정보 만료, 비활성화와 같은 추가적인 계정 상태 관리를 따로 하지 않음. + * 모든 계정은 기본적으로 활성화되어 있음. + * 추후 필요하다면 추가적인 계정 상태 관리를 위한 메서드를 추가 + */ + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/divary/global/config/security/SecurityConfig.java b/src/main/java/com/divary/global/config/security/SecurityConfig.java index e8d64cd2..76060e4d 100644 --- a/src/main/java/com/divary/global/config/security/SecurityConfig.java +++ b/src/main/java/com/divary/global/config/security/SecurityConfig.java @@ -1,12 +1,17 @@ package com.divary.global.config.security; +import com.divary.common.response.ApiResponse; import com.divary.global.config.security.jwt.JwtAuthenticationFilter; +import com.divary.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -16,23 +21,42 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ObjectMapper objectMapper; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**").disable()) - - .headers(headers -> headers.frameOptions().sameOrigin()) // ← H2 iframe 허용 + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/notification").authenticated() + .requestMatchers("/api/v1/chatrooms/**").authenticated() + .requestMatchers("api/v1/images/upload/temp").authenticated() .anyRequest().permitAll() ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(customAuthenticationEntryPoint()) + ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } + @Bean + public AuthenticationEntryPoint customAuthenticationEntryPoint() { + return (request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json;charset=UTF-8"); + + String requestPath = request.getRequestURI(); + ApiResponse errorResponse = ApiResponse.error(ErrorCode.AUTHENTICATION_REQUIRED, requestPath); + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + }; + } + @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/divary/global/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/security/jwt/JwtAuthenticationFilter.java index 48d96743..76ddc083 100644 --- a/src/main/java/com/divary/global/config/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/security/jwt/JwtAuthenticationFilter.java @@ -1,11 +1,17 @@ package com.divary.global.config.security.jwt; +import com.divary.common.response.ApiResponse; import com.divary.global.config.properties.Constants; +import com.divary.global.exception.BusinessException; +import com.divary.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -14,27 +20,64 @@ import java.io.IOException; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); + String requestURI = request.getRequestURI(); + log.debug("JWT 필터 처리 시작 - URI: {}", requestURI); + + try { + String token = resolveToken(request); + log.debug("추출된 토큰: {}", token != null ? "토큰 존재" : "토큰 없음"); - if(StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - Authentication authentication = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); + if(StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("SecurityContext에 인증 정보 설정 완료 - 사용자: {}", authentication.getName()); + } else { + log.debug("토큰이 없거나 유효하지 않음"); + } + + } catch (BusinessException e) { + log.error("JWT 인증 비즈니스 로직 오류: {}", e.getMessage()); + handleJwtException(request, response, e.getErrorCode()); + return; + } catch (Exception e) { + log.error("JWT 인증 처리 중 예상치 못한 오류 발생: {}", e.getMessage()); + handleJwtException(request, response, ErrorCode.INVALID_TOKEN); + return; } + filterChain.doFilter(request, response); } + // 인증 예외 처리 정형화된 구조로 응답하도록 설정 (GlobalExceptionHandler로 처리 불가 해서 직접 처리) + private void handleJwtException(HttpServletRequest request, HttpServletResponse response, ErrorCode errorCode) { + try { + response.setStatus(errorCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse errorResponse = ApiResponse.error(errorCode, request.getRequestURI()); + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + response.getWriter().flush(); + + } catch (IOException e) { + log.error("JWT 예외 응답 작성 중 오류 발생: {}", e.getMessage()); + } + } + private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(Constants.AUTH_HEADER); if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { diff --git a/src/main/java/com/divary/global/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/divary/global/config/security/jwt/JwtTokenProvider.java index 3e8bf35e..5fd9fb1d 100644 --- a/src/main/java/com/divary/global/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/divary/global/config/security/jwt/JwtTokenProvider.java @@ -2,7 +2,9 @@ import com.divary.global.config.properties.Constants; import com.divary.global.config.properties.JwtProperties; -import com.divary.global.exception.InvalidTokenException; +import com.divary.global.config.security.CustomUserDetailsService; +import com.divary.global.exception.BusinessException; +import com.divary.global.exception.ErrorCode; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -11,14 +13,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.security.Key; -import java.util.Collections; import java.util.Date; @Component @@ -26,6 +26,7 @@ public class JwtTokenProvider { private final JwtProperties jwtProperties; + private final CustomUserDetailsService userDetailsService; private Key getSigningKey() { return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); @@ -63,10 +64,15 @@ public Authentication getAuthentication(String token) { .getBody(); String email = claims.getSubject(); - String role = claims.get("role", String.class); - - User principal = new User(email, "", Collections.singleton(() -> role)); - return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + + try { + // CustomUserPrincipal을 통해 사용자 정보 로드 + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities()); + } catch (BusinessException e) { + // 사용자 정보를 찾을 수 없는 경우 + throw new BusinessException(ErrorCode.INVALID_USER_CONTEXT); + } } public static String resolveToken(HttpServletRequest request) { @@ -80,7 +86,7 @@ public static String resolveToken(HttpServletRequest request) { public Authentication extractAuthentication(HttpServletRequest request){ String accessToken = resolveToken(request); if(accessToken == null || !validateToken(accessToken)) { - throw new InvalidTokenException("토큰이 유효하지 않습니다."); + throw new BusinessException(ErrorCode.INVALID_TOKEN); } return getAuthentication(accessToken); } diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index 51709645..f4bce28c 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -16,7 +16,6 @@ public enum ErrorCode { // 실제 사용되는 검증 에러들 VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_001", "입력값 검증에 실패했습니다."), REQUIRED_FIELD_MISSING(HttpStatus.BAD_REQUEST, "VALIDATION_002", "필수 필드가 누락되었습니다."), - INVALID_TOKEN(HttpStatus.BAD_REQUEST, "VALIDATION_003", "토큰이 잘못되었습니다."), CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "ENCYCLOPEDIA_001", "해당 카드에 대한 정보를 찾을 수 없습니다."), @@ -26,6 +25,7 @@ public enum ErrorCode { LOG_BASE_NOT_FOUND(HttpStatus.NOT_FOUND, "LOGBOOK_001", "해당 날짜에는 로그북을 찾을 수 없습니다."), LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "LOGBOOK_002", "해당 로그북의 세부 정보를 찾을 수 없습니다."), LOG_LIMIT_EXCEEDED(HttpStatus.NOT_FOUND, "LOGBOOK_003", "로그북은 하루에 최대 3개까지만 생성할 수 있습니다."), + LOG_ACCESS_DENIED(HttpStatus.FORBIDDEN,"LOGBOOK_004","로그북에 접근 권한이 없습니다."), //맴버 관련 EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "이메일을 찾을 수 없습니다."), @@ -40,7 +40,27 @@ public enum ErrorCode { AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "AVATAR_001", "해당 유저의 아바타를 찾을 수 업습니다."), // 채팅방 관련 에러코드 - CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT_ROOM_001", "채팅방을 찾을 수 없습니다."); + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT_ROOM_001", "채팅방을 찾을 수 없습니다."), + CHAT_ROOM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "CHAT_ROOM_002", "채팅방에 접근 권한이 없습니다."), + CHAT_ROOM_MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "CHAT_ROOM_003", "메시지가 너무 깁니다."), + + // OpenAI API 관련 에러코드 + OPENAI_API_ERROR(HttpStatus.BAD_GATEWAY, "OPENAI_001", "AI 서비스에 일시적인 문제가 발생했습니다."), + OPENAI_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "OPENAI_002", "AI 서비스 사용량이 초과되었습니다."), + OPENAI_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "OPENAI_003", "AI 서비스 요청이 올바르지 않습니다."), + OPENAI_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "OPENAI_004", "AI 서비스 응답 시간이 초과되었습니다."), + + // 이미지 처리 관련 에러코드 + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE_001", "이미지 업로드에 실패했습니다."), + IMAGE_SIZE_TOO_LARGE(HttpStatus.BAD_REQUEST, "IMAGE_002", "이미지 크기와 용량이 너무 큽니다."), + IMAGE_FORMAT_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "IMAGE_003", "지원하지 않는 이미지 형식입니다."), + IMAGE_URL_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "IMAGE_004", "올바르지 않은 이미지 URL 형식입니다."), + + // 인증 관련 에러코드 강화 + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_001", "토큰이 유효하지 않습니다."), + ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_002", "액세스 토큰이 만료되었습니다."), // TODO: 토큰 만료 시 401 에러 처리 필요 + INVALID_USER_CONTEXT(HttpStatus.UNAUTHORIZED, "AUTH_003", "사용자 인증 정보가 유효하지 않습니다."), + AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTH_004", "인증이 필요합니다."); // TODO: 비즈니스 로직 개발하면서 필요한 에러코드들 추가 private final HttpStatus status; diff --git a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java index 34671c1d..3c71aab0 100644 --- a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import jakarta.servlet.http.HttpServletRequest; @Slf4j @RestControllerAdvice @@ -20,75 +21,65 @@ public class GlobalExceptionHandler { * 비즈니스 로직 예외 처리 */ @ExceptionHandler(BusinessException.class) - protected ResponseEntity> handleBusinessException(BusinessException e) { + protected ResponseEntity> handleBusinessException(BusinessException e, HttpServletRequest request) { log.error("BusinessException: {}", e.getMessage()); return ResponseEntity .status(e.getErrorCode().getStatus()) - .body(ApiResponse.error(e.getErrorCode())); + .body(ApiResponse.error(e.getErrorCode(), request.getRequestURI())); } /** * @Valid 검증 실패 및 바인딩 실패 예외 처리 */ @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) - protected ResponseEntity> handleValidationException(Exception e) { + protected ResponseEntity> handleValidationException(Exception e, HttpServletRequest request) { log.error("Validation Exception: {}", e.getMessage()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(ErrorCode.VALIDATION_ERROR)); + .body(ApiResponse.error(ErrorCode.VALIDATION_ERROR, request.getRequestURI())); } /** * 필수 파라미터 누락 예외 처리 */ @ExceptionHandler(MissingServletRequestParameterException.class) - protected ResponseEntity> handleMissingParameterException(MissingServletRequestParameterException e) { + protected ResponseEntity> handleMissingParameterException(MissingServletRequestParameterException e, HttpServletRequest request) { log.error("Missing Parameter: {}", e.getParameterName()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(ErrorCode.REQUIRED_FIELD_MISSING)); + .body(ApiResponse.error(ErrorCode.REQUIRED_FIELD_MISSING, request.getRequestURI())); } /** * 파라미터 타입 불일치 예외 처리 */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) - protected ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException e) { + protected ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) { log.error("Type Mismatch: {} for parameter {}", e.getValue(), e.getName()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE)); + .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, request.getRequestURI())); } /** * 지원하지 않는 HTTP 메서드 예외 처리 */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - protected ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + protected ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) { log.error("Method Not Supported: {}", e.getMethod()); return ResponseEntity .status(HttpStatus.METHOD_NOT_ALLOWED) - .body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED)); + .body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI())); } - // InvalidTokenException 처리 - @ExceptionHandler(InvalidTokenException.class) - protected ResponseEntity> handleInvalidTokenException(InvalidTokenException e) { - log.error("Invalid Token: {}", e.getMessage()); - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) // Unauthorized 상태 코드 반환 - .body(ApiResponse.error(ErrorCode.INVALID_TOKEN)); // 오류 코드 반환 - } - - /** * 기타 모든 예외 처리 */ @ExceptionHandler(Exception.class) - protected ResponseEntity> handleException(Exception e) { + protected ResponseEntity> handleException(Exception e, HttpServletRequest request) { log.error("Unexpected Exception: {}", e.getMessage(), e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); + .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI())); } } \ No newline at end of file