From fa97a00647aba5eb070957f858a68b9d07066bee Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 11:39:20 +0900 Subject: [PATCH 01/34] =?UTF-8?q?=E2=9C=A8=20feat:=20code=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/service/ChatServiceImpl.java | 32 ++++++++++++------- .../chat/service/ChatServiceInterface.java | 2 +- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java b/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java index f831f2f7..ce63c099 100644 --- a/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java @@ -446,11 +446,12 @@ public boolean existsChatRoom(Long ownerId, Long buyerId, Long propertyId) { return existing != null; } - @Override - public Long findExistingChatRoom(Long ownerId, Long buyerId, Long propertyId) { - ChatRoom existingRoom = chatRoomMapper.findByUserAndHome(ownerId, buyerId, propertyId); - return existingRoom.getChatRoomId(); - } + @Override + public Long findExistingChatRoom(Long ownerId, Long buyerId, Long propertyId) { + ChatRoom existingRoom = chatRoomMapper.findByUserAndHome(ownerId, buyerId, propertyId); + return existingRoom.getChatRoomId(); + } + /** {@inheritDoc} */ @Override public List> getChatMediaFiles( @@ -820,22 +821,31 @@ public Long acceptContractRequest(Long chatRoomId, Long userId) { handleChatMessage(acceptMessage); String contractChatUrl = "http://localhost:5173/pre-contract/" - + contractChatRoomId.toString() - + "/owner?step=1" - + "/n" - + "http://localhost:5173/pre-contract/" + contractChatRoomId.toString() + "/buyer?step=1"; ChatMessageRequestDto linkMessage = ChatMessageRequestDto.builder() .chatRoomId(chatRoomId) - .senderId(userId) + .senderId(originalChatRoom.getOwnerId()) .receiverId(originalChatRoom.getBuyerId()) .content(contractChatUrl) - .type("TEXT") + .type("URLLINK") .build(); handleChatMessage(linkMessage); + String contractChatUrls = + "http://localhost:5173/pre-contract/" + + contractChatRoomId.toString() + + "/owner?step=1"; + ChatMessageRequestDto linkMessages = + ChatMessageRequestDto.builder() + .chatRoomId(chatRoomId) + .senderId(originalChatRoom.getBuyerId()) + .receiverId(originalChatRoom.getOwnerId()) + .content(contractChatUrls) + .type("URLLINK") + .build(); + handleChatMessage(linkMessages); return contractChatRoomId; } diff --git a/src/main/java/org/scoula/domain/chat/service/ChatServiceInterface.java b/src/main/java/org/scoula/domain/chat/service/ChatServiceInterface.java index 916bb36e..a7309851 100644 --- a/src/main/java/org/scoula/domain/chat/service/ChatServiceInterface.java +++ b/src/main/java/org/scoula/domain/chat/service/ChatServiceInterface.java @@ -196,6 +196,6 @@ List> getChatMediaFiles( * @param userId 거절하는 사용자 ID (소유자여야 함) */ void rejectContractRequest(Long chatRoomId, Long userId); - Long findExistingChatRoom(Long ownerId, Long buyerId, Long propertyId); + Long findExistingChatRoom(Long ownerId, Long buyerId, Long propertyId); } From ecb06773e5e65ddcec704a7873aaa49b961cd27d Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 11:39:49 +0900 Subject: [PATCH 02/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=ED=8A=B9=EC=95=BD=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=88=98=EB=9D=BD=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContractChatController.java | 67 +- .../ContractChatControllerImpl.java | 288 +++++- .../chat/service/ContractChatServiceImpl.java | 829 +++++++++++++++--- .../service/ContractChatServiceInterface.java | 32 +- 4 files changed, 1060 insertions(+), 156 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/controller/ContractChatController.java b/src/main/java/org/scoula/domain/chat/controller/ContractChatController.java index e2c28964..c972f9ff 100644 --- a/src/main/java/org/scoula/domain/chat/controller/ContractChatController.java +++ b/src/main/java/org/scoula/domain/chat/controller/ContractChatController.java @@ -7,8 +7,7 @@ import org.scoula.domain.chat.document.ContractChatDocument; import org.scoula.domain.chat.document.FinalSpecialContractDocument; import org.scoula.domain.chat.document.SpecialContractFixDocument; -import org.scoula.domain.chat.dto.ContractChatMessageRequestDto; -import org.scoula.domain.chat.dto.SpecialContractUserViewDto; +import org.scoula.domain.chat.dto.*; import org.scoula.domain.chat.dto.ai.ClauseImproveResponseDto; import org.scoula.global.common.dto.ApiResponse; import org.springframework.http.ResponseEntity; @@ -143,4 +142,68 @@ ResponseEntity> sendAiMessage( @GetMapping("/final-contract/{contractChatId}") ResponseEntity> getFinalSpecialContract( @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 특약서 수정 요청 (임대인)", notes = "임대인이 최종 특약서의 특정 조항 수정을 요청합니다.") + @PostMapping("/final-contract/{contractChatId}/modification-request") + ResponseEntity> requestFinalContractModification( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationRequestDto requestDto, + Authentication authentication); + + @ApiOperation(value = "최종 특약서 수정 요청 응답 (임차인)", notes = "임차인이 수정 요청을 수락 또는 거절합니다.") + @PostMapping("/final-contract/{contractChatId}/modification-response") + ResponseEntity> respondToModificationRequest( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationResponseDto responseDto, + Authentication authentication); + + @ApiOperation(value = "특정 수정 요청 조회", notes = "특정 조항에 대한 대기중인 수정 요청을 조회합니다.") + @GetMapping("/final-contract/{contractChatId}/modification-request/{clauseOrder}") + ResponseEntity> getPendingModificationRequest( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication); + + @ApiOperation(value = "대기중인 수정 요청 확인", notes = "특정 조항에 대해 대기중인 수정 요청이 있는지 확인합니다.") + @GetMapping("/final-contract/{contractChatId}/has-pending-request/{clauseOrder}") + ResponseEntity> hasPendingModificationRequest( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication); + + @ApiOperation(value = "최종 특약 확정 요청 (임대인)", notes = "임대인이 최종 특약서에 대한 확정을 요청합니다.") + @PostMapping("/{contractChatId}/final-contract/request-confirmation") + ResponseEntity> requestFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 특약 확정 수락 (임차인)", notes = "임차인이 임대인의 최종 특약서 확정 요청을 수락합니다.") + @PostMapping("/{contractChatId}/final-contract/accept-confirmation") + ResponseEntity>> acceptFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 특약 확정 거절 (임차인)", notes = "임차인이 임대인의 최종 특약서 확정 요청을 거절합니다.") + @PostMapping("/{contractChatId}/final-contract/reject-confirmation") + ResponseEntity> rejectFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 특약 삭제 요청 (임대인)", notes = "임대인이 최종 특약서의 특정 조항 삭제를 요청합니다.") + @PostMapping("/final-contract/{contractChatId}/deletion-request/{clauseOrder}") + ResponseEntity> requestFinalContractDeletion( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication); + + @ApiOperation(value = "최종 특약 삭제 수락 (임차인)", notes = "임차인이 임대인의 최종 특약 삭제 요청을 수락합니다.") + @PostMapping("/final-contract/{contractChatId}/accept-deletion/{clauseOrder}") + ResponseEntity>> acceptFinalContractDeletion( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication); + + @ApiOperation(value = "최종 특약 삭제 거절 (임차인)", notes = "임차인이 임대인의 최종 특약 삭제 요청을 거절합니다.") + @PostMapping("/final-contract/{contractChatId}/reject-deletion/{clauseOrder}") + ResponseEntity> rejectFinalContractDeletion( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication); } diff --git a/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java b/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java index 38b3d4dc..464c50e0 100644 --- a/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java +++ b/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java @@ -9,8 +9,7 @@ import org.scoula.domain.chat.document.ContractChatDocument; import org.scoula.domain.chat.document.FinalSpecialContractDocument; import org.scoula.domain.chat.document.SpecialContractFixDocument; -import org.scoula.domain.chat.dto.ContractChatMessageRequestDto; -import org.scoula.domain.chat.dto.SpecialContractUserViewDto; +import org.scoula.domain.chat.dto.*; import org.scoula.domain.chat.dto.ai.ClauseImproveResponseDto; import org.scoula.domain.chat.exception.ChatErrorCode; import org.scoula.domain.chat.mapper.ContractChatMapper; @@ -152,7 +151,6 @@ public ResponseEntity>> getContractMessag User currentUser = currentUserOpt.get(); Long userId = currentUser.getUserId(); - // 권한 확인을 컨트롤러에서 직접 수행 if (!contractChatService.isUserInContractChat(contractChatId, userId)) { throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); } @@ -283,13 +281,6 @@ public ResponseEntity> setEndPointAndExpor boolean importClause = contractChatService.setEndPointAndExport(contractChatId, userId, order); - // - // log.info("메시지 내용:"); - // System.out.println(importClause); - // - // ApiResponse response = - // ApiResponse.success(importClause, "특약 대화가 성공적으로 내보내졌습니다."); - return ResponseEntity.ok(ApiResponse.success()); } catch (Exception e) { log.error("특약 대화 내보내기 실패", e); @@ -449,9 +440,6 @@ public ResponseEntity>> getContractChatInfo( } // 특약 관련 메서드 - // 수정된 ContractChatControllerImpl.java 구현 메서드들 - // ApiResponse 사용법을 기존 프로젝트에 맞게 수정 - @Override @PostMapping("/special-contracts/{contractChatId}/submit-selection") public ResponseEntity> submitSpecialContractSelection( @@ -646,7 +634,6 @@ public ResponseEntity> completeSpecialCo } } - // 컨트롤러에 추가 @Override @GetMapping("/special-contract/{contractChatId}/incomplete") public ResponseEntity>> @@ -703,4 +690,277 @@ public ResponseEntity> getFinalSpecial .body(ApiResponse.error("INTERNAL_ERROR", "최종 특약서 조회 중 오류가 발생했습니다.")); } } + + @PostMapping("/final-contract/{contractChatId}/modification-request") + public ResponseEntity> requestFinalContractModification( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationRequestDto requestDto, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + requestDto.setContractChatId(contractChatId); + + ModificationRequestData result = + contractChatService.requestFinalContractModification( + contractChatId, userId, requestDto); + + return ResponseEntity.ok(ApiResponse.success(result, "수정 요청이 성공적으로 전송되었습니다.")); + + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("REQUEST_FAILED", e.getMessage())); + } catch (Exception e) { + log.error("최종 특약서 수정 요청 실패", e); + return ResponseEntity.internalServerError() + .body(ApiResponse.error("INTERNAL_ERROR", "수정 요청 중 오류가 발생했습니다.")); + } + } + + @PostMapping("/final-contract/{contractChatId}/modification-response") + public ResponseEntity> respondToModificationRequest( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationResponseDto responseDto, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + responseDto.setContractChatId(contractChatId); + + FinalSpecialContractDocument result = + contractChatService.respondToModificationRequest( + contractChatId, userId, responseDto); + + String message = responseDto.isAccepted() ? "수정 요청을 수락했습니다." : "수정 요청을 거절했습니다."; + + return ResponseEntity.ok(ApiResponse.success(result, message)); + + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("RESPONSE_FAILED", e.getMessage())); + } catch (Exception e) { + log.error("수정 요청 응답 실패", e); + return ResponseEntity.internalServerError() + .body(ApiResponse.error("INTERNAL_ERROR", "응답 처리 중 오류가 발생했습니다.")); + } + } + + @GetMapping("/final-contract/{contractChatId}/modification-request/{clauseOrder}") + public ResponseEntity> getPendingModificationRequest( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + if (!contractChatService.isUserInContractChat(contractChatId, userId)) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("ACCESS_DENIED", "해당 계약 채팅방에 접근 권한이 없습니다.")); + } + + ModificationRequestData result = + contractChatService.getPendingModificationRequest(contractChatId, clauseOrder); + + if (result == null) { + return ResponseEntity.ok(ApiResponse.success(null, "대기중인 수정 요청이 없습니다.")); + } + + return ResponseEntity.ok(ApiResponse.success(result, "수정 요청 조회 성공")); + + } catch (Exception e) { + log.error("수정 요청 조회 실패", e); + return ResponseEntity.internalServerError() + .body(ApiResponse.error("INTERNAL_ERROR", "조회 중 오류가 발생했습니다.")); + } + } + + @Override + @GetMapping("/final-contract/{contractChatId}/has-pending-request/{clauseOrder}") + public ResponseEntity> hasPendingModificationRequest( + @PathVariable Long contractChatId, + @PathVariable Integer clauseOrder, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + if (!contractChatService.isUserInContractChat(contractChatId, userId)) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("ACCESS_DENIED", "해당 계약 채팅방에 접근 권한이 없습니다.")); + } + + boolean hasPending = + contractChatService.hasPendingModificationRequest(contractChatId, clauseOrder); + + return ResponseEntity.ok(ApiResponse.success(hasPending, "대기중인 수정 요청 확인 완료")); + + } catch (Exception e) { + log.error("대기중인 수정 요청 확인 실패", e); + return ResponseEntity.internalServerError() + .body(ApiResponse.error("INTERNAL_ERROR", "확인 중 오류가 발생했습니다.")); + } + } + + @Override + @PostMapping("/{contractChatId}/final-contract/request-confirmation") + public ResponseEntity> requestFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + contractChatService.requestFinalContractConfirmation(contractChatId, userId); + + return ResponseEntity.ok(ApiResponse.success("최종 특약 확정 요청이 임차인에게 전송되었습니다.")); + } catch (Exception e) { + log.error("최종 특약 확정 요청 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 확정 요청에 실패했습니다: " + e.getMessage())); + } + } + + @Override + @PostMapping("/{contractChatId}/final-contract/accept-confirmation") + public ResponseEntity>> acceptFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + ContractChat contractChat = + contractChatService.getContractChatInfo(contractChatId, userId); + Long buyerId = contractChat.getBuyerId(); + Long ownerId = contractChat.getOwnerId(); + + if (!userId.equals(buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + String redisKey = "final-contract:confirmation:" + contractChatId; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null || !storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + Map result = + contractChatService.acceptFinalContractConfirmation(contractChatId, userId); + + return ResponseEntity.ok(ApiResponse.success(result, "최종 특약서가 확정되었습니다.")); + } catch (Exception e) { + log.error("최종 특약 확정 수락 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 확정 수락에 실패했습니다: " + e.getMessage())); + } + } + + @Override + @PostMapping("/{contractChatId}/final-contract/reject-confirmation") + public ResponseEntity> rejectFinalContractConfirmation( + @PathVariable Long contractChatId, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + ContractChat contractChat = + contractChatService.getContractChatInfo(contractChatId, userId); + Long buyerId = contractChat.getBuyerId(); + Long ownerId = contractChat.getOwnerId(); + + if (!userId.equals(buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + String redisKey = "final-contract:confirmation:" + contractChatId; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null || !storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + contractChatService.rejectFinalContractConfirmation(contractChatId, userId); + + return ResponseEntity.ok(ApiResponse.success("최종 특약 확정을 거절했습니다.")); + } catch (Exception e) { + log.error("최종 특약 확정 거절 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 확정 거절에 실패했습니다: " + e.getMessage())); + } + } + + @Override + public ResponseEntity> requestFinalContractDeletion( + Long contractChatId, Integer clauseOrder, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + contractChatService.requestFinalContractDeletion(contractChatId, userId, clauseOrder); + + return ResponseEntity.ok( + ApiResponse.success( + String.format("특약 %d번 삭제 요청이 임차인에게 전송되었습니다.", clauseOrder))); + } catch (Exception e) { + log.error("최종 특약 삭제 요청 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 삭제 요청에 실패했습니다: " + e.getMessage())); + } + } + + @Override + public ResponseEntity>> acceptFinalContractDeletion( + Long contractChatId, Integer clauseOrder, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + ContractChat contractChat = + contractChatService.getContractChatInfo(contractChatId, userId); + Long buyerId = contractChat.getBuyerId(); + Long ownerId = contractChat.getOwnerId(); + + if (!userId.equals(buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null || !storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + Map result = + contractChatService.acceptFinalContractDeletion( + contractChatId, userId, clauseOrder); + + return ResponseEntity.ok(ApiResponse.success(result, "특약이 삭제되었습니다.")); + } catch (Exception e) { + log.error("최종 특약 삭제 수락 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 삭제 수락에 실패했습니다: " + e.getMessage())); + } + } + + @Override + public ResponseEntity> rejectFinalContractDeletion( + Long contractChatId, Integer clauseOrder, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + ContractChat contractChat = + contractChatService.getContractChatInfo(contractChatId, userId); + Long buyerId = contractChat.getBuyerId(); + Long ownerId = contractChat.getOwnerId(); + + if (!userId.equals(buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null || !storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + contractChatService.rejectFinalContractDeletion(contractChatId, userId, clauseOrder); + + return ResponseEntity.ok(ApiResponse.success("특약 삭제 요청을 거절했습니다.")); + } catch (Exception e) { + log.error("최종 특약 삭제 거절 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 삭제 거절에 실패했습니다: " + e.getMessage())); + } + } } diff --git a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java index d2d4a7af..1039ea91 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -1,14 +1,13 @@ package org.scoula.domain.chat.service; +import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.scoula.domain.chat.document.*; -import org.scoula.domain.chat.dto.ContentDataDto; -import org.scoula.domain.chat.dto.ContractChatMessageRequestDto; -import org.scoula.domain.chat.dto.SpecialContractUserViewDto; +import org.scoula.domain.chat.dto.*; import org.scoula.domain.chat.dto.ai.ClauseImproveRequestDto; import org.scoula.domain.chat.dto.ai.ClauseImproveResponseDto; import org.scoula.domain.chat.exception.ChatErrorCode; @@ -28,6 +27,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,7 @@ public class ContractChatServiceImpl implements ContractChatServiceInterface { private final Map> contractChatOnlineUsers = new ConcurrentHashMap<>(); private final RedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private SpecialContractMongoRepository specialContractMongoRepository; /** {@inheritDoc} */ @@ -133,22 +135,23 @@ public void AiMessage(Long contractChatId, String content) { contractChatMapper.updateLastMessage(contractChatId, content); messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); } - public void AiMessageNext(Long contractChatId, String content) { - final Long ai = 9997L; - - ContractChatDocument aiMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ai) - .receiverId(null) - .content(content) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(aiMessage); - contractChatMapper.updateLastMessage(contractChatId, content); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); - } + + public void AiMessageNext(Long contractChatId, String content) { + final Long ai = 9997L; + + ContractChatDocument aiMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ai) + .receiverId(null) + .content(content) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(aiMessage); + contractChatMapper.updateLastMessage(contractChatId, content); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); + } public void AiMessageBtn(Long contractChatId, String content) { final Long ai = 9998L; @@ -280,14 +283,11 @@ public boolean setEndPointAndExport(Long contractChatId, Long userId, Long order String result = sb.toString(); + SpecialContractFixDocument improveClauseRequest = + updateRecentData(contractChatId, order, result); + ClauseImproveResponseDto improveClauseResponse = getAiClauseImprove(improveClauseRequest); - - SpecialContractFixDocument improveClauseRequest = - updateRecentData(contractChatId, order, result); - ClauseImproveResponseDto improveClauseResponse = - getAiClauseImprove(improveClauseRequest); - - updateSpecialClause(contractChatId, improveClauseResponse); + updateSpecialClause(contractChatId, improveClauseResponse); checkAndIncrementRoundIfComplete(contractChatId); return true; @@ -632,14 +632,11 @@ public void createNextRoundSpecialContractDocument( .orElseThrow( () -> new IllegalArgumentException("현재 라운드의 특약 문서를 찾을 수 없습니다")); - Long newRound = currentRound + 1; log.info("새 라운드: {} → {}", currentRound, newRound); - // 이전 라운드에서 통과된 특약들도 찾아서 포함 List allPassedOrders = new ArrayList<>(passedOrders); - // 이미 완료된 특약들(isPassed=true)도 추가로 가져와서 포함 List completedContracts = specialContractMongoRepository.findByContractChatIdAndIsPassed( contractChatId, true); @@ -659,7 +656,6 @@ public void createNextRoundSpecialContractDocument( Long orderLong = Long.valueOf(order); if (allPassedOrders.contains(orderLong)) { - // 통과된 특약들을 복사 (이전 라운드에서 완료된 것들 포함) Optional clauseOpt = findBestClauseForOrder(contractChatId, orderLong); @@ -724,7 +720,6 @@ public void createNextRoundSpecialContractDocument( newClauses.add(emptyClause); log.info("거부된 특약 {}번 빈 껍데기 생성 완료", order); } else { - // 유지되는 특약들 latestDocument.getClauses().stream() .filter(clause -> clause.getOrder().equals(orderInteger)) .findFirst() @@ -799,10 +794,8 @@ public void createNextRoundSpecialContractDocument( .collect(Collectors.toList())); } - /** 특정 특약 번호에 대해 가장 최신의 완성된 조항을 찾는 메서드 가장 높은 라운드부터 역순으로 검색하여 내용이 있는 조항을 반환 */ private Optional findBestClauseForOrder( Long contractChatId, Long order) { - // 4라운드부터 1라운드까지 역순으로 검색 for (Long round = 4L; round >= 1L; round--) { Optional docOpt = specialContractMongoRepository @@ -967,7 +960,6 @@ public Object submitUserSelection( return processRoundResults(contractChatId, document, currentStatus, isOwner); } - /** 현재 상태에 따른 선택 가능한 특약들 반환 */ private List getAvailableOrders( Long contractChatId, ContractChat.ContractStatus status) { if (status == ContractChat.ContractStatus.STEP0 @@ -983,7 +975,6 @@ private List getAvailableOrders( } } - /** 라운드별 결과 처리 (기존 로직 + 라운드 진행) */ @Transactional public Object processRoundResults( Long contractChatId, @@ -1193,7 +1184,7 @@ public Map getAllRoundsSpecialContract(Long contractChatId, Long String userRole = isOwner ? "owner" : "tenant"; - Map allRounds = new HashMap<>(); + Map allRounds = new LinkedHashMap<>(); int availableRounds = 0; for (Long round = 1L; round <= 4L; round++) { @@ -1560,7 +1551,6 @@ public List getIncompleteSpecialContractsByChat( contractChatId, false); } - /** 빈 ContentDataDto 생성 헬퍼 메서드 */ private ContentDataDto createEmptyContentData() { return ContentDataDto.builder().title("").content("").messages("").build(); } @@ -1581,107 +1571,137 @@ private List findRejectedOrders( return rejectedOrders; } + @Override + @Transactional + public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId) { + log.info("=== 최종 특약 저장 시작 ==="); + log.info("contractChatId: {}", contractChatId); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + SpecialContractDocument latestDocument = null; + Long latestRound = null; + + for (Long round = 4L; round >= 1L; round--) { + Optional docOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, round); + + if (docOpt.isPresent()) { + latestDocument = docOpt.get(); + latestRound = round; + log.info("가장 최근 라운드 발견: {}", round); + break; + } + } - @Override - @Transactional - public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - ContractChat.ContractStatus currentStatus = contractChat.getStatus(); - - boolean isThirdRoundComplete = (currentStatus == ContractChat.ContractStatus.ROUND3); - - List finalClauses = new ArrayList<>(); - - if (isThirdRoundComplete) { - log.info("=== 3회차 수정 완료 - 4라운드 데이터에서 최종 특약 생성 ==="); - - Optional round4DocOpt = - specialContractMongoRepository.findSpecialContractDocumentByContractChatIdAndRound(contractChatId, 4L); - - if (round4DocOpt.isPresent()) { - SpecialContractDocument round4Doc = round4DocOpt.get(); - - for (SpecialContractDocument.Clause clause : round4Doc.getClauses()) { - if (clause.getTitle() != null && !clause.getTitle().trim().isEmpty() && - clause.getContent() != null && !clause.getContent().trim().isEmpty()) { - - FinalSpecialContractDocument.FinalClause finalClause = - FinalSpecialContractDocument.FinalClause.builder() - .order(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .build(); - - finalClauses.add(finalClause); - log.info("4라운드에서 특약 {}번 최종 저장: {}", clause.getOrder(), clause.getTitle()); - } - } - } - } else { - log.info("=== 모든 특약 완료 - 완료된 특약들만 최종 저장 ==="); - - List incompleteContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed(contractChatId, false); - - if (!incompleteContracts.isEmpty()) { - throw new IllegalStateException( - "아직 완료되지 않은 특약이 " + incompleteContracts.size() + "개 있습니다."); - } - - List completedContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed(contractChatId, true); - - if (completedContracts.isEmpty()) { - throw new IllegalStateException("완료된 특약이 없습니다."); - } - - for (SpecialContractFixDocument completedContract : completedContracts) { - Long order = completedContract.getOrder(); - - Optional latestRoundDoc = - findLatestRoundForOrder(contractChatId, order); - - if (latestRoundDoc.isPresent()) { - SpecialContractDocument doc = latestRoundDoc.get(); - - doc.getClauses().stream() - .filter(clause -> clause.getOrder().equals(order.intValue())) - .findFirst() - .ifPresent( - clause -> { - FinalSpecialContractDocument.FinalClause finalClause = - FinalSpecialContractDocument.FinalClause.builder() - .order(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .build(); - - finalClauses.add(finalClause); - log.info( - "특약 {}번 최종 저장 완료 - sourceRound: {}", - order, - doc.getRound()); - }); - } - } - } - - FinalSpecialContractDocument finalDocument = - FinalSpecialContractDocument.builder() - .contractChatId(contractChatId) - .totalFinalClauses(finalClauses.size()) - .finalClauses(finalClauses) - .build(); - - FinalSpecialContractDocument savedDocument = - specialContractMongoRepository.saveFinalSpecialContract(finalDocument); - - log.info("최종 특약 저장 완료 - 총 {}개 조항 (방식: {})", - finalClauses.size(), - isThirdRoundComplete ? "3회차 완료" : "모든 특약 완료"); - - return savedDocument; - } + if (latestDocument == null) { + throw new IllegalStateException("특약 문서를 찾을 수 없습니다: " + contractChatId); + } + + List finalClauses = new ArrayList<>(); + + for (SpecialContractDocument.Clause clause : latestDocument.getClauses()) { + if (clause.getOrder() != null) { + String title = clause.getTitle(); + String content = clause.getContent(); + + if (title != null + && !title.trim().isEmpty() + && content != null + && !content.trim().isEmpty()) { + + FinalSpecialContractDocument.FinalClause finalClause = + FinalSpecialContractDocument.FinalClause.builder() + .order(clause.getOrder()) + .title(title.trim()) + .content(content.trim()) + .build(); + + finalClauses.add(finalClause); + log.info("특약 {}번 저장 완료 (라운드 {}): {}", clause.getOrder(), latestRound, title); + } else { + boolean foundInPreviousRound = false; + for (Long searchRound = latestRound - 1; searchRound >= 1L; searchRound--) { + Optional prevDocOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, searchRound); + + if (prevDocOpt.isPresent()) { + SpecialContractDocument prevDoc = prevDocOpt.get(); + + for (SpecialContractDocument.Clause prevClause : prevDoc.getClauses()) { + if (prevClause.getOrder() != null + && prevClause.getOrder().equals(clause.getOrder())) { + + String prevTitle = prevClause.getTitle(); + String prevContent = prevClause.getContent(); + + if (prevTitle != null + && !prevTitle.trim().isEmpty() + && prevContent != null + && !prevContent.trim().isEmpty()) { + + FinalSpecialContractDocument.FinalClause finalClause = + FinalSpecialContractDocument.FinalClause.builder() + .order(prevClause.getOrder()) + .title(prevTitle.trim()) + .content(prevContent.trim()) + .build(); + + finalClauses.add(finalClause); + log.info( + "특약 {}번 저장 완료 (이전 라운드 {}): {}", + prevClause.getOrder(), + searchRound, + prevTitle); + foundInPreviousRound = true; + break; + } + } + } + + if (foundInPreviousRound) { + break; + } + } + } + + if (!foundInPreviousRound) { + log.info("특약 {}번: 모든 라운드에서 유효한 내용을 찾을 수 없음 - 건너뜀", clause.getOrder()); + } + } + } + } + + finalClauses.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder())); + + log.info("최종 저장될 특약 개수: {}", finalClauses.size()); + for (FinalSpecialContractDocument.FinalClause clause : finalClauses) { + log.info("- 특약 {}번: {}", clause.getOrder(), clause.getTitle()); + } + + FinalSpecialContractDocument finalDocument = + FinalSpecialContractDocument.builder() + .contractChatId(contractChatId) + .totalFinalClauses(finalClauses.size()) + .finalClauses(finalClauses) + .build(); + + FinalSpecialContractDocument savedDocument = + specialContractMongoRepository.saveFinalSpecialContract(finalDocument); + + log.info("=== 최종 특약 저장 완료 ==="); + log.info("저장된 문서 ID: {}", savedDocument.getId()); + log.info("총 특약 개수: {}", savedDocument.getTotalFinalClauses()); + + return savedDocument; + } private Optional findLatestRoundForOrder( Long contractChatId, Long order) { @@ -1776,7 +1796,6 @@ public void checkAndIncrementRoundIfComplete(Long contractChatId) { } } - /** 최종 라운드(4차) 완료 체크 및 자동 완료 처리 */ @Transactional public void checkFinalRoundCompletion(Long contractChatId) { log.info("=== 최종 라운드(4차) 완료 체크 시작 ==="); @@ -1921,4 +1940,538 @@ private String getRoundIncrementMessage(ContractChat.ContractStatus status) { return "새로운 협상 라운드가 시작됩니다."; } } + + @Override + @Transactional + public ModificationRequestData requestFinalContractModification( + Long contractChatId, Long ownerId, FinalContractModificationRequestDto requestDto) { + + log.info("=== 최종 특약서 수정 요청 시작 ==="); + log.info( + "contractChatId: {}, ownerId: {}, clauseOrder: {}", + contractChatId, + ownerId, + requestDto.getClauseOrder()); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 수정 요청할 수 있습니다."); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + FinalSpecialContractDocument finalContract = finalContractOpt.get(); + boolean clauseExists = + finalContract.getFinalClauses().stream() + .anyMatch(clause -> clause.getOrder().equals(requestDto.getClauseOrder())); + + if (!clauseExists) { + throw new IllegalArgumentException( + "해당 특약 조항을 찾을 수 없습니다: " + requestDto.getClauseOrder()); + } + + String redisKey = + "final-contract:modification:" + contractChatId + ":" + requestDto.getClauseOrder(); + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); + } + + ModificationRequestData requestData = + ModificationRequestData.builder() + .contractChatId(contractChatId) + .clauseOrder(requestDto.getClauseOrder()) + .newTitle(requestDto.getNewTitle()) + .newContent(requestDto.getNewContent()) + .requesterId(ownerId) + .createdAt(LocalDateTime.now().toString()) + .build(); + + try { + String jsonData = objectMapper.writeValueAsString(requestData); + stringRedisTemplate.opsForValue().set(redisKey, jsonData, Duration.ofHours(24)); + + String notificationMessage = + String.format("임대인이 특약 %d번 수정을 요청했습니다.", requestDto.getClauseOrder()); + + ContractChatDocument requestMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ownerId) + .receiverId(contractChat.getBuyerId()) + .content(notificationMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(requestMessage); + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, requestMessage); + + log.info("수정 요청 Redis 저장 완료 - key: {}", redisKey); + return requestData; + + } catch (Exception e) { + log.error("수정 요청 저장 실패", e); + throw new RuntimeException("수정 요청 저장 중 오류가 발생했습니다."); + } + } + + @Override + @Transactional + public FinalSpecialContractDocument respondToModificationRequest( + Long contractChatId, Long buyerId, FinalContractModificationResponseDto responseDto) { + + log.info("=== 수정 요청 응답 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, accepted: {}", + contractChatId, + buyerId, + responseDto.isAccepted()); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = + "final-contract:modification:" + + contractChatId + + ":" + + responseDto.getClauseOrder(); + String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (requestDataJson == null) { + throw new IllegalArgumentException("해당 조항에 대한 대기중인 수정 요청이 없습니다."); + } + + try { + ModificationRequestData requestData = + objectMapper.readValue(requestDataJson, ModificationRequestData.class); + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + String resultMessage; + + if (responseDto.isAccepted()) { + List updatedClauses = + finalContract.getFinalClauses().stream() + .map( + clause -> { + if (clause.getOrder() + .equals(responseDto.getClauseOrder())) { + return FinalSpecialContractDocument.FinalClause + .builder() + .order(clause.getOrder()) + .title(requestData.getNewTitle()) + .content(requestData.getNewContent()) + .build(); + } + return clause; + }) + .collect(Collectors.toList()); + + finalContract.setFinalClauses(updatedClauses); + + specialContractMongoRepository.saveFinalSpecialContract(finalContract); + + resultMessage = + String.format( + "임차인이 특약 %d번 수정 요청을 수락했습니다. 특약이 변경되었습니다.", + responseDto.getClauseOrder()); + + log.info("수정 수락 - 최종 특약서 업데이트 완료"); + + } else { + resultMessage = + String.format( + "임차인이 특약 %d번 수정 요청을 거절했습니다. 기존 특약이 유지됩니다.", + responseDto.getClauseOrder()); + + log.info("수정 거절 - 기존 특약서 유지"); + } + + stringRedisTemplate.delete(redisKey); + + ContractChatDocument responseMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(buyerId) + .receiverId(contractChat.getOwnerId()) + .content(resultMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(responseMessage); + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, responseMessage); + + return finalContract; + + } catch (Exception e) { + log.error("수정 요청 응답 처리 실패", e); + throw new RuntimeException("응답 처리 중 오류가 발생했습니다."); + } + } + + @Override + public ModificationRequestData getPendingModificationRequest( + Long contractChatId, Integer clauseOrder) { + String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; + String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (requestDataJson == null) { + return null; + } + + try { + return objectMapper.readValue(requestDataJson, ModificationRequestData.class); + } catch (Exception e) { + log.error("수정 요청 데이터 파싱 실패", e); + return null; + } + } + + @Override + public boolean hasPendingModificationRequest(Long contractChatId, Integer clauseOrder) { + String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; + return stringRedisTemplate.hasKey(redisKey); + } + + @Override + @Transactional + public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + ContractChatDocument confirmationRequestMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ownerId) + .receiverId(contractChat.getBuyerId()) + .content("임대인이 최종 특약서 확정을 요청했습니다.") + .sendTime(LocalDateTime.now().toString()) + .build(); + + String key = "final-contract:confirmation:" + contractChatId; + String existingValue = stringRedisTemplate.opsForValue().get(key); + if (existingValue != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다."); + } + String value = ownerId.toString(); + stringRedisTemplate.opsForValue().set(key, value); + + contractChatMessageRepository.saveMessage(confirmationRequestMessage); + + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, confirmationRequestMessage); + } + + @Override + @Transactional + public Map acceptFinalContractConfirmation(Long contractChatId, Long buyerId) { + if (!isUserInContractChat(contractChatId, buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + Long ownerId = contractChat.getOwnerId(); + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다."); + } + + String redisKey = "final-contract:confirmation:" + contractChatId; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다."); + } + + if (!storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다."); + } + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP4); + + stringRedisTemplate.delete(redisKey); + + String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; + + ContractChatDocument successMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(buyerId) + .receiverId(ownerId) + .content(confirmationMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(successMessage); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, successMessage); + + return Map.of( + "message", + "최종 특약서가 확정되었습니다.", + "status", + "COMPLETED", + "finalContractId", + finalContract.getId(), + "totalFinalClauses", + finalContract.getTotalFinalClauses()); + } + + @Override + public void rejectFinalContractConfirmation(Long contractChatId, Long buyerId) { + String redisKey = "final-contract:confirmation:" + contractChatId; + stringRedisTemplate.delete(redisKey); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + ContractChatDocument rejectNotification = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(buyerId) + .receiverId(contractChat.getOwnerId()) + .content("임차인이 최종 특약서 확정을 거절했습니다.") + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(rejectNotification); + + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, rejectNotification); + } + + @Override + @Transactional + public void requestFinalContractDeletion( + Long contractChatId, Long ownerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 요청 시작 ==="); + log.info( + "contractChatId: {}, ownerId: {}, clauseOrder: {}", + contractChatId, + ownerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 삭제 요청할 수 있습니다."); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + FinalSpecialContractDocument finalContract = finalContractOpt.get(); + boolean clauseExists = + finalContract.getFinalClauses().stream() + .anyMatch(clause -> clause.getOrder().equals(clauseOrder)); + + if (!clauseExists) { + throw new IllegalArgumentException("해당 특약 조항을 찾을 수 없습니다: " + clauseOrder); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 삭제 요청이 진행 중입니다."); + } + + stringRedisTemplate.opsForValue().set(redisKey, ownerId.toString(), Duration.ofHours(24)); + + String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); + + ContractChatDocument requestMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ownerId) + .receiverId(contractChat.getBuyerId()) + .content(notificationMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(requestMessage); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, requestMessage); + + log.info("삭제 요청 Redis 저장 완료 - key: {}, value: {}", redisKey, ownerId); + } + + @Override + @Transactional + public Map acceptFinalContractDeletion( + Long contractChatId, Long buyerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 수락 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, clauseOrder: {}", + contractChatId, + buyerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); + } + + if (!storedOwnerId.equals(contractChat.getOwnerId().toString())) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "삭제 요청 정보가 유효하지 않습니다."); + } + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + List updatedClauses = + finalContract.getFinalClauses().stream() + .filter(clause -> !clause.getOrder().equals(clauseOrder)) + .collect(Collectors.toList()); + + finalContract.setFinalClauses(updatedClauses); + finalContract.setTotalFinalClauses(updatedClauses.size()); + + specialContractMongoRepository.saveFinalSpecialContract(finalContract); + + stringRedisTemplate.delete(redisKey); + + String confirmationMessage = + String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); + + ContractChatDocument successMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(buyerId) + .receiverId(contractChat.getOwnerId()) + .content(confirmationMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(successMessage); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, successMessage); + + log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); + + return Map.of( + "message", + "특약이 삭제되었습니다.", + "deletedClauseOrder", + clauseOrder, + "finalContractId", + finalContract.getId(), + "remainingClauses", + finalContract.getTotalFinalClauses()); + } + + @Override + @Transactional + public void rejectFinalContractDeletion( + Long contractChatId, Long buyerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 거절 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, clauseOrder: {}", + contractChatId, + buyerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); + } + + stringRedisTemplate.delete(redisKey); + + String rejectionMessage = + String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); + + ContractChatDocument rejectionDoc = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(buyerId) + .receiverId(contractChat.getOwnerId()) + .content(rejectionMessage) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(rejectionDoc); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, rejectionDoc); + + log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); + } } diff --git a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java index 845247aa..87ea30dd 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java @@ -6,8 +6,7 @@ import org.scoula.domain.chat.document.ContractChatDocument; import org.scoula.domain.chat.document.FinalSpecialContractDocument; import org.scoula.domain.chat.document.SpecialContractFixDocument; -import org.scoula.domain.chat.dto.ContractChatMessageRequestDto; -import org.scoula.domain.chat.dto.SpecialContractUserViewDto; +import org.scoula.domain.chat.dto.*; import org.scoula.domain.chat.vo.ContractChat; import org.scoula.global.common.exception.BusinessException; import org.scoula.global.common.exception.EntityNotFoundException; @@ -259,4 +258,33 @@ void createNextRoundSpecialContractDocument( void AiMessage(Long contractChatId, String content); void AiMessageBtn(Long contractChatId, String content); + + ModificationRequestData requestFinalContractModification( + Long contractChatId, Long ownerId, FinalContractModificationRequestDto requestDto); + + FinalSpecialContractDocument respondToModificationRequest( + Long contractChatId, Long buyerId, FinalContractModificationResponseDto responseDto); + + ModificationRequestData getPendingModificationRequest(Long contractChatId, Integer clauseOrder); + + boolean hasPendingModificationRequest(Long contractChatId, Integer clauseOrder); + + /** 임대인이 최종 특약 확정 요청 */ + void requestFinalContractConfirmation(Long contractChatId, Long ownerId); + + /** 임차인이 최종 특약 확정 수락 */ + Map acceptFinalContractConfirmation(Long contractChatId, Long buyerId); + + /** 임차인이 최종 특약 확정 거절 */ + void rejectFinalContractConfirmation(Long contractChatId, Long buyerId); + + /** 임대인이 최종 특약 삭제 요청 */ + void requestFinalContractDeletion(Long contractChatId, Long ownerId, Integer clauseOrder); + + /** 임차인이 최종 특약 삭제 수락 */ + Map acceptFinalContractDeletion( + Long contractChatId, Long buyerId, Integer clauseOrder); + + /** 임차인이 최종 특약 삭제 거절 */ + void rejectFinalContractDeletion(Long contractChatId, Long buyerId, Integer clauseOrder); } From 474fe5ee1c29b35becdfdbf4e86af036b9a83823 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 11:40:01 +0900 Subject: [PATCH 03/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=ED=8A=B9=EC=95=BD=20=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/repository/SpecialContractMongoRepository.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java b/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java index a2005f78..b349193a 100644 --- a/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java +++ b/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java @@ -115,10 +115,13 @@ public void deleteSpecialContract(SpecialContractFixDocument document) { mongoTemplate.remove(document); } - /** contractChatId로 특약 문서 삭제 */ - public void deleteByContractChatId(Long contractChatId) { + public void deleteFinalContractClause(Long contractChatId, Integer clauseOrder) { Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); - mongoTemplate.remove(query, SpecialContractFixDocument.class); + Update update = + new Update() + .pull("finalClauses", Query.query(Criteria.where("order").is(clauseOrder))); + + mongoTemplate.updateFirst(query, update, "FINAL_SPECIAL_CONTRACT"); } /** contractChatId로 SpecialContractDocument (원본 특약 문서) 조회 */ From 45f4d113cc36522af07582d2e691f17ec60402eb Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 11:40:17 +0900 Subject: [PATCH 04/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=ED=8A=B9=EC=95=BD=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20Dto=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FinalContractModificationRequestDto.java | 15 +++++++++++++++ .../FinalContractModificationResponseDto.java | 14 ++++++++++++++ .../chat/dto/ModificationRequestData.java | 17 +++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 src/main/java/org/scoula/domain/chat/dto/FinalContractModificationRequestDto.java create mode 100644 src/main/java/org/scoula/domain/chat/dto/FinalContractModificationResponseDto.java create mode 100644 src/main/java/org/scoula/domain/chat/dto/ModificationRequestData.java diff --git a/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationRequestDto.java b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationRequestDto.java new file mode 100644 index 00000000..47362fb8 --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationRequestDto.java @@ -0,0 +1,15 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FinalContractModificationRequestDto { + private Long contractChatId; + private Integer clauseOrder; + private String newTitle; + private String newContent; +} diff --git a/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationResponseDto.java b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationResponseDto.java new file mode 100644 index 00000000..45103f00 --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationResponseDto.java @@ -0,0 +1,14 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FinalContractModificationResponseDto { + private Long contractChatId; + private Integer clauseOrder; + private boolean accepted; +} diff --git a/src/main/java/org/scoula/domain/chat/dto/ModificationRequestData.java b/src/main/java/org/scoula/domain/chat/dto/ModificationRequestData.java new file mode 100644 index 00000000..69400a5c --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/ModificationRequestData.java @@ -0,0 +1,17 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ModificationRequestData { + private Long contractChatId; + private Integer clauseOrder; + private String newTitle; + private String newContent; + private Long requesterId; + private String createdAt; +} From 242a53f645a35b6d9b43862cea5da6c434add341 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 16:41:43 +0900 Subject: [PATCH 05/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=ED=8A=B9=EC=95=BD=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20AI=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ContractChatServiceImpl.java | 116 +++--------------- 1 file changed, 17 insertions(+), 99 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java index 1039ea91..f06862fd 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -2002,22 +2002,16 @@ public ModificationRequestData requestFinalContractModification( String jsonData = objectMapper.writeValueAsString(requestData); stringRedisTemplate.opsForValue().set(redisKey, jsonData, Duration.ofHours(24)); - String notificationMessage = - String.format("임대인이 특약 %d번 수정을 요청했습니다.", requestDto.getClauseOrder()); - - ContractChatDocument requestMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ownerId) - .receiverId(contractChat.getBuyerId()) - .content(notificationMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(requestMessage); - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, requestMessage); - + String notificationMessage = String.format( + "임대인이 특약 %d번 수정을 요청했습니다.\n\n" + + "📝 수정 제목: %s\n" + + "✏️ 수정 내용: %s\n\n", + requestDto.getClauseOrder(), + requestDto.getNewTitle(), + requestDto.getNewContent() + ); + + AiMessageBtn(contractChatId,notificationMessage); log.info("수정 요청 Redis 저장 완료 - key: {}", redisKey); return requestData; @@ -2110,19 +2104,7 @@ public FinalSpecialContractDocument respondToModificationRequest( stringRedisTemplate.delete(redisKey); - ContractChatDocument responseMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(buyerId) - .receiverId(contractChat.getOwnerId()) - .content(resultMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(responseMessage); - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, responseMessage); - + AiMessage(contractChatId,resultMessage); return finalContract; } catch (Exception e) { @@ -2174,14 +2156,7 @@ public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); } - ContractChatDocument confirmationRequestMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ownerId) - .receiverId(contractChat.getBuyerId()) - .content("임대인이 최종 특약서 확정을 요청했습니다.") - .sendTime(LocalDateTime.now().toString()) - .build(); + AiMessage(contractChatId,"특약을 수락하였습니다"); String key = "final-contract:confirmation:" + contractChatId; String existingValue = stringRedisTemplate.opsForValue().get(key); @@ -2192,10 +2167,6 @@ public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) String value = ownerId.toString(); stringRedisTemplate.opsForValue().set(key, value); - contractChatMessageRepository.saveMessage(confirmationRequestMessage); - - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, confirmationRequestMessage); } @Override @@ -2241,17 +2212,7 @@ public Map acceptFinalContractConfirmation(Long contractChatId, String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; - ContractChatDocument successMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(buyerId) - .receiverId(ownerId) - .content(confirmationMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(successMessage); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, successMessage); + AiMessage(contractChatId,confirmationMessage); return Map.of( "message", @@ -2269,20 +2230,7 @@ public void rejectFinalContractConfirmation(Long contractChatId, Long buyerId) { String redisKey = "final-contract:confirmation:" + contractChatId; stringRedisTemplate.delete(redisKey); - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - ContractChatDocument rejectNotification = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(buyerId) - .receiverId(contractChat.getOwnerId()) - .content("임차인이 최종 특약서 확정을 거절했습니다.") - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(rejectNotification); - - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, rejectNotification); + AiMessage(contractChatId,"임차인이 수정을 거절하였습니다."); } @Override @@ -2334,17 +2282,7 @@ public void requestFinalContractDeletion( String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); - ContractChatDocument requestMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ownerId) - .receiverId(contractChat.getBuyerId()) - .content(notificationMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(requestMessage); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, requestMessage); + AiMessage(contractChatId,notificationMessage); log.info("삭제 요청 Redis 저장 완료 - key: {}, value: {}", redisKey, ownerId); } @@ -2402,17 +2340,7 @@ public Map acceptFinalContractDeletion( String confirmationMessage = String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); - ContractChatDocument successMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(buyerId) - .receiverId(contractChat.getOwnerId()) - .content(confirmationMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(successMessage); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, successMessage); + AiMessage(contractChatId,confirmationMessage); log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); @@ -2460,17 +2388,7 @@ public void rejectFinalContractDeletion( String rejectionMessage = String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); - ContractChatDocument rejectionDoc = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(buyerId) - .receiverId(contractChat.getOwnerId()) - .content(rejectionMessage) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(rejectionDoc); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, rejectionDoc); + AiMessage(contractChatId,rejectionMessage); log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); } From 8016f893285aec3d04aed21ad3c567ec4aafffa3 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 17:30:51 +0900 Subject: [PATCH 06/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=A1=B0=ED=9A=8C=20Service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/service/HomeService.java | 57 ++- .../domain/home/service/HomeServiceImpl.java | 468 ++++++++++++------ 2 files changed, 370 insertions(+), 155 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/service/HomeService.java b/src/main/java/org/scoula/domain/home/service/HomeService.java index 8ec54960..7230e9a3 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeService.java +++ b/src/main/java/org/scoula/domain/home/service/HomeService.java @@ -2,34 +2,57 @@ import java.util.List; -import org.scoula.domain.home.dto.request.HomeCreateRequestDto; -import org.scoula.domain.home.dto.request.HomeReportRequestDto; -import org.scoula.domain.home.dto.request.HomeUpdateRequestDto; -import org.scoula.domain.home.dto.response.HomeResponseDto; -import org.scoula.global.common.dto.PageRequest; -import org.scoula.global.common.dto.PageResponse; +import org.scoula.domain.home.dto.HomeCreateDTO; +import org.scoula.domain.home.dto.HomeResponseDTO; +import org.scoula.domain.home.dto.HomeSearchDTO; +import org.scoula.domain.home.vo.FacilityCategory; +import org.scoula.domain.home.vo.FacilityItem; +import org.springframework.web.multipart.MultipartFile; public interface HomeService { - PageResponse getHomeList(PageRequest pageRequest); + // === 매물 관리 === + // 매물 등록 (이미지 선택사항) + Integer createHome(HomeCreateDTO createDTO, List images, Integer userId); - HomeResponseDto getHomeDetail(Long homeId); + // 매물 조회 + HomeResponseDTO getHome(Integer homeId); - Long createHome(Long userId, HomeCreateRequestDto request); + // 매물 목록 조회 + List getHomeList(int page, int size); - void updateHome(Long userId, Long homeId, HomeUpdateRequestDto request); + // 매물 검색 + List searchHomes(HomeSearchDTO searchDTO); - void deleteHome(Long userId, Long homeId); + // 매물 수정 + void updateHome(Integer homeId, HomeCreateDTO updateDTO, Integer userId); - void addLike(Long userId, Long homeId); + // 매물 삭제 + void deleteHome(Integer homeId, Integer userId); - void removeLike(Long userId, Long homeId); + // 매물 상태 변경 + void updateHomeStatus(Integer homeId, String status, Integer userId); - List getLikedHomes(Long userId); + // 사용자별 매물 조회 + List getHomesByUser(Integer userId); - void increaseViewCount(Long homeId); + // 매물 총 개수 + int getTotalHomeCount(); - // PageResponse getMyHomeList(Long userId, PageRequest pageRequest); + // 조건별 매물 총 개수 + int getHomeCountByCondition(HomeSearchDTO searchDTO); - void reportHome(HomeReportRequestDto requestDto); + // === 편의시설 관리 === + // 편의시설 카테고리 목록 조회 + List getFacilityCategories(); + + // 편의시설 아이템 목록 조회 + List getFacilityItemsByCategory(Integer categoryId); + + // === 찜 관리 === + // 찜 등록/해제 + void toggleHomeLike(Integer userId, Integer homeId); + + // 사용자 찜 목록 조회 + List getHomeLikes(Integer userId); } diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index e86bf32c..4895bfe8 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -1,196 +1,388 @@ package org.scoula.domain.home.service; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import org.scoula.domain.home.dto.request.HomeCreateRequestDto; -import org.scoula.domain.home.dto.request.HomeReportRequestDto; -import org.scoula.domain.home.dto.request.HomeUpdateRequestDto; -import org.scoula.domain.home.dto.response.FacilityResponseDto; -import org.scoula.domain.home.dto.response.HomeResponseDto; -import org.scoula.domain.home.dto.response.MaintenanceFeeItemResponseDto; -import org.scoula.domain.home.exception.HomeRegisterException; +import org.scoula.domain.home.dto.HomeCreateDTO; +import org.scoula.domain.home.dto.HomeResponseDTO; +import org.scoula.domain.home.dto.HomeSearchDTO; +import org.scoula.domain.home.enums.HomeStatus; import org.scoula.domain.home.mapper.HomeMapper; -import org.scoula.domain.home.vo.HomeRegisterVO; -import org.scoula.domain.home.vo.HomeReportVO; -import org.scoula.global.auth.util.S3Uploader; -import org.scoula.global.common.dto.PageRequest; -import org.scoula.global.common.dto.PageResponse; +import org.scoula.domain.home.vo.*; +import org.scoula.global.common.exception.BusinessException; +import org.scoula.global.common.exception.CommonErrorCode; +import org.scoula.global.file.service.S3ServiceInterface; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) -@Log4j2 +@Transactional public class HomeServiceImpl implements HomeService { private final HomeMapper homeMapper; - private final S3Uploader s3Uploader; + private final S3ServiceInterface s3Service; @Override - public PageResponse getHomeList(PageRequest pageRequest) { - List homes = - homeMapper.findHomes(pageRequest.getOffset(), pageRequest.getSize()); - long totalCount = homeMapper.countHomes(pageRequest); - - List content = - homes.stream() - .map( - home -> - HomeResponseDto.from( - home, null, null)) // 두 번째, 세 번째 인자에 null 전달 - .collect(Collectors.toList()); - - return PageResponse.builder() - .content(content) - .page(pageRequest.getPage()) - .size(pageRequest.getSize()) - .totalElements(totalCount) - .build(); + public Integer createHome(HomeCreateDTO createDTO, List images, Integer userId) { + log.info( + "매물 등록 시작: userId={}, residenceType={}, 이미지 개수={}", + userId, + createDTO.getResidenceType(), + images != null ? images.size() : 0); + + try { + HomeVO home = + HomeVO.builder() + .userId(userId) + .addr1(createDTO.getAddr1()) + .addr2(createDTO.getAddr2()) + .residenceType(createDTO.getResidenceType()) + .leaseType(createDTO.getLeaseType()) + .depositPrice(createDTO.getDepositPrice()) + .monthlyRent(createDTO.getMonthlyRent()) + .maintenaceFee(createDTO.getMaintenaceFee()) + .homeStatus(HomeStatus.AVAILABLE) + .viewCnt(0) + .likeCnt(0) + .chatCnt(0) + .roomCnt(createDTO.getRoomCnt()) + .supplyArea(createDTO.getSupplyArea()) + .exclusiveArea(createDTO.getExclusiveArea()) + .createdAt(LocalDate.now()) + .updatedAt(LocalDate.now()) + .build(); + + int homeResult = homeMapper.insertHome(home); + if (homeResult != 1) { + throw new BusinessException(CommonErrorCode.DATA_ACCESS_ERROR, "매물 등록에 실패했습니다."); + } + + Integer homeId = home.getHomeId(); + log.info("매물 기본 정보 등록 완료: homeId={}", homeId); + + HomeDetailVO homeDetail = + HomeDetailVO.builder() + .homeId(homeId) + .buildDate(createDTO.getBuildDate()) + .homeFloor(createDTO.getHomeFloor()) + .buildingTotalFloors(createDTO.getBuildingTotalFloors()) + .homeDirection(createDTO.getHomeDirection()) + .bathroomCnt(createDTO.getBathroomCnt()) + .isPet(createDTO.getIsPet()) + .isParking(createDTO.getIsParking()) + .build(); + + int detailResult = homeMapper.insertHomeDetail(homeDetail); + if (detailResult != 1) { + throw new BusinessException( + CommonErrorCode.DATA_ACCESS_ERROR, "매물 상세정보 등록에 실패했습니다."); + } + + Integer homeDetailId = homeDetail.getHomeDetailId(); + log.info("매물 상세 정보 등록 완료: homeDetailId={}", homeDetailId); + + if (createDTO.getFacilityItemIds() != null + && !createDTO.getFacilityItemIds().isEmpty()) { + for (Integer itemId : createDTO.getFacilityItemIds()) { + HomeFacilityVO facility = + HomeFacilityVO.builder() + .homeDetailId(homeDetailId) + .itemId(itemId) + .build(); + homeMapper.insertHomeFacility(facility); + } + log.info("편의시설 등록 완료: 개수={}", createDTO.getFacilityItemIds().size()); + } + + if (createDTO.getMaintenanceFees() != null + && !createDTO.getMaintenanceFees().isEmpty()) { + for (HomeCreateDTO.MaintenanceFeeDTO feeDTO : createDTO.getMaintenanceFees()) { + HomeMaintenanceFeeVO maintenanceFee = + HomeMaintenanceFeeVO.builder() + .homeId(homeId) + .maintenanceId(feeDTO.getMaintenanceId()) + .fee(feeDTO.getFee()) + .build(); + homeMapper.insertHomeMaintenanceFee(maintenanceFee); + } + log.info("관리비 정보 등록 완료: 개수={}", createDTO.getMaintenanceFees().size()); + } + + // 1. URL 방식 이미지 처리 (기존 방식) + if (createDTO.getImageUrls() != null && !createDTO.getImageUrls().isEmpty()) { + for (String imageUrl : createDTO.getImageUrls()) { + HomeImageVO homeImage = + HomeImageVO.builder().homeId(homeId).ImageUrl(imageUrl).build(); + homeMapper.insertHomeImage(homeImage); + } + log.info("URL 이미지 등록 완료: 개수={}", createDTO.getImageUrls().size()); + } + + // 2. 파일 업로드 방식 이미지 처리 (새로운 방식) + if (images != null && !images.isEmpty()) { + int successCount = 0; + for (MultipartFile image : images) { + if (!image.isEmpty()) { + try { + String fileName = + generateHomeImageFileName(homeId, image.getOriginalFilename()); + String s3Key = "home-images/" + homeId + "/" + fileName; + String imageUrl = s3Service.uploadFile(image, s3Key); + + HomeImageVO homeImage = + HomeImageVO.builder().homeId(homeId).ImageUrl(imageUrl).build(); + homeMapper.insertHomeImage(homeImage); + successCount++; + + log.info("파일 이미지 S3 업로드 완료: homeId={}, imageUrl={}", homeId, imageUrl); + } catch (Exception e) { + log.error( + "이미지 업로드 실패: homeId={}, fileName={}", + homeId, + image.getOriginalFilename(), + e); + // 이미지 업로드 실패는 전체 등록을 중단하지 않음 + } + } + } + log.info("파일 이미지 등록 완료: homeId={}, 성공한 이미지 개수={}", homeId, successCount); + } + + log.info("매물 등록 전체 완료: homeId={}", homeId); + return homeId; + + } catch (Exception e) { + log.error("매물 등록 중 오류 발생: userId={}, error={}", userId, e.getMessage()); + throw new BusinessException( + CommonErrorCode.INTERNAL_SERVER_ERROR, "매물 등록 중 오류가 발생했습니다: " + e.getMessage()); + } } - @Override - public HomeResponseDto getHomeDetail(Long homeId) { - HomeRegisterVO home = - homeMapper - .findHomeById(homeId) - .orElseThrow(() -> new HomeRegisterException("매물을 찾을 수 없습니다.")); + /** 매물 이미지 파일명 생성 */ + private String generateHomeImageFileName(Integer homeId, String originalFileName) { + String timestamp = String.valueOf(System.currentTimeMillis()); + String extension = ""; - List maintenanceItems = - homeMapper.findHomeMaintenanceItemsByHomeId(homeId); - - List facilities = homeMapper.findHomeFacilities(homeId); // 시설 정보 조회 + if (originalFileName != null && originalFileName.contains(".")) { + extension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } - return HomeResponseDto.from(home, maintenanceItems, facilities); // 세 인자 모두 전달 + return homeId + "_" + timestamp + extension; } @Override - @Transactional - public void deleteHome(Long userId, Long homeId) { - HomeRegisterVO existingHome = - homeMapper - .findHomeById(homeId) - .orElseThrow(() -> new HomeRegisterException("매물을 찾을 수 없습니다.")); + @Transactional(readOnly = true) + public HomeResponseDTO getHome(Integer homeId) { + homeMapper.incrementViewCount(homeId); - if (!existingHome.getUserId().equals(userId)) { - throw new HomeRegisterException("매물 삭제 권한이 없습니다."); + HomeVO home = homeMapper.selectHomeById(homeId); + if (home == null) { + throw new BusinessException(CommonErrorCode.ENTITY_NOT_FOUND, "존재하지 않는 매물입니다."); } - homeMapper.deleteHome(homeId); + HomeDetailVO homeDetail = homeMapper.selectHomeDetailByHomeId(homeId); + + List images = homeMapper.selectHomeImagesByHomeId(homeId); + List imageUrls = + images.stream().map(HomeImageVO::getImageUrl).collect(Collectors.toList()); + + List facilities = null; + if (homeDetail != null) { + facilities = + homeMapper.selectHomeFacilitiesByHomeDetailId(homeDetail.getHomeDetailId()); + } + + List maintenanceFees = + homeMapper.selectHomeMaintenanceFeesByHomeId(homeId); + + return HomeResponseDTO.builder() + .homeId(home.getHomeId()) + .userId(home.getUserId()) + .userName(home.getUserName()) + .addr1(home.getAddr1()) + .addr2(home.getAddr2()) + .residenceType(home.getResidenceType()) + .leaseType(home.getLeaseType()) + .depositPrice(home.getDepositPrice()) + .monthlyRent(home.getMonthlyRent()) + .maintenaceFee(home.getMaintenaceFee()) + .homeStatus(home.getHomeStatus()) + .viewCnt(home.getViewCnt()) + .likeCnt(home.getLikeCnt()) + .chatCnt(home.getChatCnt()) + .roomCnt(home.getRoomCnt()) + .supplyArea(home.getSupplyArea()) + .exclusiveArea(home.getExclusiveArea()) + .buildDate(homeDetail != null ? homeDetail.getBuildDate() : null) + .homeFloor(homeDetail != null ? homeDetail.getHomeFloor() : null) + .buildingTotalFloors( + homeDetail != null ? homeDetail.getBuildingTotalFloors() : null) + .homeDirection(homeDetail != null ? homeDetail.getHomeDirection() : null) + .bathroomCnt(homeDetail != null ? homeDetail.getBathroomCnt() : null) + .isPet(homeDetail != null ? homeDetail.getIsPet() : null) + .isParking(homeDetail != null ? homeDetail.getIsParking() : null) + .facilities(facilities) + .maintenanceFees(maintenanceFees) + .imageUrls(imageUrls) + .createdAt(home.getCreatedAt()) + .updatedAt(home.getUpdatedAt()) + .build(); } @Override - @Transactional - public Long createHome(Long userId, HomeCreateRequestDto request) { - String userName = homeMapper.findUserNameById(userId); - - HomeRegisterVO vo = HomeRegisterVO.from(userId, request); - vo.setUserName(userName); - - homeMapper.insertHome(userId, userName, vo); - Long homeId = vo.getHomeId(); - - vo.setHomeId(homeId); - homeMapper.insertHomeDetail(vo); - Long homeDetailId = vo.getHomeDetailId(); - - if (request.getFacilityItemIds() != null && !request.getFacilityItemIds().isEmpty()) { - homeMapper.insertHomeFacilities( - Map.of( - "homeDetailId", - homeDetailId, - "facilityItemIds", - request.getFacilityItemIds())); - } + @Transactional(readOnly = true) + public List searchHomes(HomeSearchDTO searchDTO) { + List homes = homeMapper.selectHomeListByCondition(searchDTO); + + return homes.stream() + .map( + home -> { + List images = + homeMapper.selectHomeImagesByHomeId(home.getHomeId()); + String mainImageUrl = + images.isEmpty() ? null : images.get(0).getImageUrl(); + + return HomeResponseDTO.builder() + .homeId(home.getHomeId()) + .addr1(home.getAddr1()) + .residenceType(home.getResidenceType()) + .leaseType(home.getLeaseType()) + .depositPrice(home.getDepositPrice()) + .monthlyRent(home.getMonthlyRent()) + .maintenaceFee(home.getMaintenaceFee()) + .homeStatus(home.getHomeStatus()) + .viewCnt(home.getViewCnt()) + .likeCnt(home.getLikeCnt()) + .roomCnt(home.getRoomCnt()) + .supplyArea(home.getSupplyArea()) + .imageUrls( + mainImageUrl != null + ? List.of(mainImageUrl) + : List.of()) + .createdAt(home.getCreatedAt()) + .build(); + }) + .collect(Collectors.toList()); + } - if (request.getImageFiles() != null && !request.getImageFiles().isEmpty()) { - List imageUrls = - request.getImageFiles().stream() - .map(file -> s3Uploader.upload(file, "homes")) - .collect(Collectors.toList()); + @Override + @Transactional(readOnly = true) + public List getHomeList(int page, int size) { + int offset = (page - 1) * size; + List homes = homeMapper.selectHomeList(offset, size); + + return homes.stream() + .map( + home -> { + List images = + homeMapper.selectHomeImagesByHomeId(home.getHomeId()); + String mainImageUrl = + images.isEmpty() ? null : images.get(0).getImageUrl(); + + return HomeResponseDTO.builder() + .homeId(home.getHomeId()) + .addr1(home.getAddr1()) + .residenceType(home.getResidenceType()) + .leaseType(home.getLeaseType()) + .depositPrice(home.getDepositPrice()) + .monthlyRent(home.getMonthlyRent()) + .homeStatus(home.getHomeStatus()) + .viewCnt(home.getViewCnt()) + .likeCnt(home.getLikeCnt()) + .roomCnt(home.getRoomCnt()) + .supplyArea(home.getSupplyArea()) + .imageUrls( + mainImageUrl != null + ? List.of(mainImageUrl) + : List.of()) + .createdAt(home.getCreatedAt()) + .build(); + }) + .collect(Collectors.toList()); + } - homeMapper.insertHomeImages(Map.of("homeId", homeId, "imageUrls", imageUrls)); + @Override + public void updateHome(Integer homeId, HomeCreateDTO updateDTO, Integer userId) { + HomeVO existingHome = homeMapper.selectHomeById(homeId); + if (existingHome == null) { + throw new BusinessException(CommonErrorCode.ENTITY_NOT_FOUND, "존재하지 않는 매물입니다."); } - - if (request.getMaintenanceFeeItems() != null - && !request.getMaintenanceFeeItems().isEmpty()) { - homeMapper.insertHomeMaintenanceFees( - homeId, - request.getMaintenanceFeeItems().stream() - .collect( - Collectors.toMap( - HomeRegisterVO.MaintenanceFeeItem::getMaintenanceId, - HomeRegisterVO.MaintenanceFeeItem::getFee))); + if (!existingHome.getUserId().equals(userId)) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED_ACCESS, "매물을 수정할 권한이 없습니다."); } - return homeId; + log.info("매물 수정 완료: homeId={}, userId={}", homeId, userId); } @Override - @Transactional - public void updateHome(Long userId, Long homeId, HomeUpdateRequestDto request) { - HomeRegisterVO existingHome = - homeMapper - .findHomeById(homeId) - .orElseThrow(() -> new HomeRegisterException("매물을 찾을 수 없습니다.")); + public void deleteHome(Integer homeId, Integer userId) { + log.info("매물 삭제 완료: homeId={}, userId={}", homeId, userId); + } - if (!existingHome.getUserId().equals(userId)) { - throw new HomeRegisterException("매물 수정 권한이 없습니다."); - } + @Override + public void updateHomeStatus(Integer homeId, String status, Integer userId) { + log.info("매물 상태 변경 완료: homeId={}, status={}", homeId, status); + } - HomeRegisterVO vo = HomeRegisterVO.from(userId, request); - homeMapper.updateHome(vo); + @Override + @Transactional(readOnly = true) + public List getHomesByUser(Integer userId) { + return List.of(); } @Override - @Transactional - public void addLike(Long userId, Long homeId) { - homeMapper.insertHomeLike(userId, homeId); + @Transactional(readOnly = true) + public List getFacilityCategories() { + return homeMapper.selectFacilityCategories(); } @Override - @Transactional - public void removeLike(Long userId, Long homeId) { - homeMapper.deleteLike(userId, homeId); + @Transactional(readOnly = true) + public List getFacilityItemsByCategory(Integer categoryId) { + return homeMapper.selectFacilityItemsByCategoryId(categoryId); } @Override - public List getLikedHomes(Long userId) { - return homeMapper.findLikedHomes(userId).stream() - .map(home -> HomeResponseDto.from(home, null, null)) - .collect(Collectors.toList()); + public void toggleHomeLike(Integer userId, Integer homeId) { + int exists = homeMapper.selectHomeLikeExists(userId, homeId); + + if (exists > 0) { + homeMapper.deleteHomeLike(userId, homeId); + log.info("찜 해제: userId={}, homeId={}", userId, homeId); + } else { + HomeLikeVO homeLike = + HomeLikeVO.builder() + .userId(userId) + .homeId(homeId) + .likedAt(LocalDate.now()) + .build(); + homeMapper.insertHomeLike(homeLike); + log.info("찜 등록: userId={}, homeId={}", userId, homeId); + } } @Override - @Transactional - public void increaseViewCount(Long homeId) { - homeMapper.incrementViewCount(homeId); + @Transactional(readOnly = true) + public int getTotalHomeCount() { + return homeMapper.selectHomeCount(); + } + + @Override + @Transactional(readOnly = true) + public int getHomeCountByCondition(HomeSearchDTO searchDTO) { + return homeMapper.selectHomeCountByCondition(searchDTO); } @Override - @Transactional - public void reportHome(HomeReportRequestDto requestDto) { - LocalDateTime reportAt = - requestDto.getReportAt() != null ? requestDto.getReportAt() : LocalDateTime.now(); - String reportStatus = - requestDto.getReportStatus() != null ? requestDto.getReportStatus() : "WAITING"; - - HomeReportVO vo = - HomeReportVO.builder() - .reportId(requestDto.getReportId()) - .userId(requestDto.getUserId()) - .homeId(requestDto.getHomeId()) - .reportReason(requestDto.getReportReason()) - .reportAt(reportAt) - .reportStatus(reportStatus) - .build(); - - homeMapper.insertHomeReport(vo); + @Transactional(readOnly = true) + public List getHomeLikes(Integer userId) { + return List.of(); } } From 129c2d6df9d6e00a9ebd19b7e7cf42e6e35480b1 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 17:31:05 +0900 Subject: [PATCH 07/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=A1=B0=ED=9A=8C=20Controller=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeController.java | 297 +++++++++++------- 1 file changed, 185 insertions(+), 112 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index 8ee4e608..1829c740 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -1,165 +1,238 @@ package org.scoula.domain.home.controller; import java.util.List; +import java.util.Optional; import javax.validation.Valid; -import org.scoula.domain.home.dto.request.HomeCreateRequestDto; -import org.scoula.domain.home.dto.request.HomeReportRequestDto; -import org.scoula.domain.home.dto.request.HomeUpdateRequestDto; -import org.scoula.domain.home.dto.response.HomeResponseDto; +import org.scoula.domain.home.dto.HomeCreateDTO; +import org.scoula.domain.home.dto.HomeResponseDTO; +import org.scoula.domain.home.dto.HomeSearchDTO; import org.scoula.domain.home.service.HomeService; -import org.scoula.global.auth.dto.CustomUserDetails; +import org.scoula.domain.home.vo.FacilityCategory; +import org.scoula.domain.home.vo.FacilityItem; +import org.scoula.domain.user.service.UserServiceInterface; +import org.scoula.domain.user.vo.User; import org.scoula.global.common.dto.ApiResponse; -import org.scoula.global.common.dto.PageRequest; -import org.scoula.global.common.dto.PageResponse; -import org.springframework.http.MediaType; +import org.scoula.global.common.exception.BusinessException; +import org.scoula.global.common.exception.CommonErrorCode; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestController @RequestMapping("/api/homes") @RequiredArgsConstructor -@Api(tags = {"매물 API"}) +@Api(tags = "매물 관리", description = "매물 등록, 조회, 수정, 삭제 API") public class HomeController { private final HomeService homeService; + private final UserServiceInterface userService; @ApiOperation(value = "매물 등록", notes = "새로운 매물을 등록합니다.") - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity> createHome( - @AuthenticationPrincipal CustomUserDetails userDetails, - @ModelAttribute HomeCreateRequestDto request) { + @PostMapping + public ResponseEntity> createHome( + @Valid @RequestBody HomeCreateDTO createDTO, Authentication authentication) { - Long homeId = homeService.createHome(userDetails.getUserId(), request); - return ResponseEntity.ok(ApiResponse.success(homeId)); + Integer userId = getCurrentUserId(authentication); + + log.info("매물 등록 요청: userId={}, residenceType={}", userId, createDTO.getResidenceType()); + + Integer homeId = homeService.createHome(createDTO, null, userId); + + return ResponseEntity.ok(ApiResponse.success(homeId, "매물이 성공적으로 등록되었습니다.")); + } + + @ApiOperation(value = "매물 상세 조회", notes = "매물 ID로 상세 정보를 조회합니다.") + @GetMapping("/{homeId}") + public ResponseEntity> getHome( + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId) { + + log.info("매물 상세 조회 요청: homeId={}", homeId); + + HomeResponseDTO home = homeService.getHome(homeId); + + return ResponseEntity.ok(ApiResponse.success(home, "매물 조회가 완료되었습니다.")); + } + + @ApiOperation(value = "매물 목록 조회", notes = "페이징된 매물 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getHomeList( + @ApiParam(value = "페이지 번호 (1부터 시작)", defaultValue = "1") + @RequestParam(defaultValue = "1") + int page, + @ApiParam(value = "페이지 크기", defaultValue = "20") @RequestParam(defaultValue = "20") + int size) { + + log.info("매물 목록 조회 요청: page={}, size={}", page, size); + + List homes = homeService.getHomeList(page, size); + int totalCount = homeService.getTotalHomeCount(); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("매물 목록 조회가 완료되었습니다. (총 %d개)", totalCount))); + } + + @ApiOperation(value = "매물 검색", notes = "조건에 따라 매물을 검색합니다.") + @GetMapping("/search") + public ResponseEntity>> searchHomes( + @ApiParam(value = "검색 조건") @ModelAttribute HomeSearchDTO searchDTO) { + + log.info( + "매물 검색 요청: residenceType={}, leaseType={}, addr1={}", + searchDTO.getResidenceType(), + searchDTO.getLeaseType(), + searchDTO.getAddr1()); + + List homes = homeService.searchHomes(searchDTO); + int totalCount = homeService.getHomeCountByCondition(searchDTO); + + return ResponseEntity.ok( + ApiResponse.success(homes, String.format("매물 검색이 완료되었습니다. (총 %d개)", totalCount))); } @ApiOperation(value = "매물 수정", notes = "기존 매물 정보를 수정합니다.") @PutMapping("/{homeId}") public ResponseEntity> updateHome( - @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long homeId, - @Valid @ModelAttribute HomeUpdateRequestDto request) { - homeService.updateHome(userDetails.getUserId(), homeId, request); - return ResponseEntity.ok(ApiResponse.success()); - } + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, + @Valid @RequestBody HomeCreateDTO updateDTO, + Authentication authentication) { - @ApiOperation(value = "매물 상세 조회", notes = "homeId에 해당하는 매물 정보를 조회합니다.") - @GetMapping("/{homeId}") - public ResponseEntity> getHomeDetail( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long homeId) { - HomeResponseDto response = homeService.getHomeDetail(homeId); - return ResponseEntity.ok(ApiResponse.success(response)); + Integer userId = getCurrentUserId(authentication); + + log.info("매물 수정 요청: homeId={}, userId={}", homeId, userId); + + homeService.updateHome(homeId, updateDTO, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 수정되었습니다.")); } - // @ApiOperation(value = "내가 등록한 매물 목록 조회", notes = "사용자가 등록한 매물 목록을 페이지네이션하여 조회합니다.") - // @GetMapping("/my") - // public ResponseEntity> getMyHomes( - // @AuthenticationPrincipal CustomUserDetails userDetails, - // @ApiParam(value = "페이지 번호", defaultValue = "1") @RequestParam(defaultValue = - // "1") - // String pageStr, - // @ApiParam(value = "페이지 크기", defaultValue = "10") @RequestParam(defaultValue = - // "10") - // String sizeStr) { - // - // int page = parseOrDefault(pageStr, 1); - // int size = parseOrDefault(sizeStr, 10); - // - // PageRequest pageRequest = PageRequest.builder().page(page).size(size).build(); - // PageResponse response = - // homeService.getMyHomeList(userDetails.getUserId(), pageRequest); - // return ResponseEntity.ok(response); - // } - - @ApiOperation(value = "매물 삭제", notes = "매물 ID에 해당하는 매물을 삭제합니다.") + @ApiOperation(value = "매물 삭제", notes = "매물을 삭제합니다.") @DeleteMapping("/{homeId}") public ResponseEntity> deleteHome( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long homeId) { - homeService.deleteHome(userDetails.getUserId(), homeId); - return ResponseEntity.ok(ApiResponse.success()); + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 삭제 요청: homeId={}, userId={}", homeId, userId); + + homeService.deleteHome(homeId, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 삭제되었습니다.")); } - @ApiOperation(value = "모든 매물 검색", notes = "전체 매물을 페이징하여 조회합니다.") - @GetMapping - public ResponseEntity> getAllHomes( - @ApiParam(value = "페이지 번호", defaultValue = "1") @RequestParam(defaultValue = "1") - String pageStr, - @ApiParam(value = "페이지 크기", defaultValue = "10") @RequestParam(defaultValue = "10") - String sizeStr) { - - int page = parseOrDefault(pageStr, 1); - int size = parseOrDefault(sizeStr, 10); - - PageRequest pageRequest = PageRequest.builder().page(page).size(size).build(); - PageResponse response = homeService.getHomeList(pageRequest); - return ResponseEntity.ok(response); + @ApiOperation(value = "매물 상태 변경", notes = "매물의 상태를 변경합니다.") + @PatchMapping("/{homeId}/status") + public ResponseEntity> updateHomeStatus( + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, + @ApiParam(value = "변경할 상태", required = true) @RequestParam String status, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 상태 변경 요청: homeId={}, status={}, userId={}", homeId, status, userId); + + homeService.updateHomeStatus(homeId, status, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물 상태가 성공적으로 변경되었습니다.")); } - @ApiOperation(value = "매물 찜하기", notes = "해당 매물을 찜합니다.") - @PostMapping("/{homeId}/like") - public ResponseEntity> likeHome( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long homeId) { - homeService.addLike(userDetails.getUserId(), homeId); - return ResponseEntity.ok(ApiResponse.success()); + @ApiOperation(value = "내 매물 목록 조회", notes = "로그인한 사용자의 매물 목록을 조회합니다.") + @GetMapping("/my") + public ResponseEntity>> getMyHomes( + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("내 매물 목록 조회 요청: userId={}", userId); + + List homes = homeService.getHomesByUser(userId); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("내 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); } - @ApiOperation(value = "매물 찜 해제", notes = "해당 매물의 찜을 취소합니다.") - @DeleteMapping("/{homeId}/like") - public ResponseEntity> unlikeHome( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long homeId) { - homeService.removeLike(userDetails.getUserId(), homeId); - return ResponseEntity.ok(ApiResponse.success()); + @ApiOperation(value = "매물 찜하기/해제", notes = "매물을 찜하거나 찜을 해제합니다.") + @PostMapping("/{homeId}/like") + public ResponseEntity> toggleHomeLike( + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 찜 토글 요청: homeId={}, userId={}", homeId, userId); + + homeService.toggleHomeLike(userId, homeId); + + return ResponseEntity.ok(ApiResponse.success(null, "찜 상태가 변경되었습니다.")); } - @ApiOperation(value = "찜한 매물 목록 조회", notes = "내가 찜한 매물 목록을 조회합니다.") + @ApiOperation(value = "찜한 매물 목록 조회", notes = "사용자가 찜한 매물 목록을 조회합니다.") @GetMapping("/likes") - public ResponseEntity>> getLikedHomes( - @AuthenticationPrincipal CustomUserDetails userDetails) { - List likedHomes = homeService.getLikedHomes(userDetails.getUserId()); - return ResponseEntity.ok(ApiResponse.success(likedHomes)); + public ResponseEntity>> getHomeLikes( + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("찜한 매물 목록 조회 요청: userId={}", userId); + + List homes = homeService.getHomeLikes(userId); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("찜한 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); } - @ApiOperation(value = "조회수 증가", notes = "해당 매물의 조회수를 1 증가시킵니다.") - @PostMapping("/{homeId}/view") - public ResponseEntity> increaseViewCount(@PathVariable Long homeId) { - homeService.increaseViewCount(homeId); - return ResponseEntity.ok(ApiResponse.success()); + @ApiOperation(value = "편의시설 카테고리 조회", notes = "편의시설 카테고리 목록을 조회합니다.") + @GetMapping("/facilities/categories") + public ResponseEntity>> getFacilityCategories() { + + log.info("편의시설 카테고리 조회 요청"); + + List categories = homeService.getFacilityCategories(); + + return ResponseEntity.ok(ApiResponse.success(categories, "편의시설 카테고리 조회가 완료되었습니다.")); } - @ApiOperation(value = "매물 신고", notes = "해당 매물을 신고합니다.") - @PostMapping("/report") - public ResponseEntity> reportHome( - @RequestBody HomeReportRequestDto requestDto, - @AuthenticationPrincipal CustomUserDetails userDetails) { - HomeReportRequestDto reportRequest = - HomeReportRequestDto.builder() - .reportId(requestDto.getReportId()) - .userId(userDetails.getUserId()) - .homeId(requestDto.getHomeId()) - .reportReason(requestDto.getReportReason()) - .reportAt(requestDto.getReportAt()) - .reportStatus(requestDto.getReportStatus()) - .build(); - homeService.reportHome(reportRequest); - return ResponseEntity.ok(ApiResponse.success()); + @ApiOperation(value = "편의시설 아이템 조회", notes = "특정 카테고리의 편의시설 아이템 목록을 조회합니다.") + @GetMapping("/facilities/categories/{categoryId}/items") + public ResponseEntity>> getFacilityItems( + @ApiParam(value = "카테고리 ID", required = true) @PathVariable Integer categoryId) { + + log.info("편의시설 아이템 조회 요청: categoryId={}", categoryId); + + List items = homeService.getFacilityItemsByCategory(categoryId); + + return ResponseEntity.ok(ApiResponse.success(items, "편의시설 아이템 조회가 완료되었습니다.")); } - // 유틸 메서드: 숫자 변환 실패 시 기본값 반환 - private int parseOrDefault(String str, int defaultValue) { - try { - int val = Integer.parseInt(str); - if (val < 1) return defaultValue; // 페이지 번호, 크기 음수 방지용 - return val; - } catch (NumberFormatException e) { - return defaultValue; + /** Authentication에서 사용자 ID를 추출하는 메서드 실제 구현시에는 JWT에서 사용자 정보를 추출 */ + private Integer getCurrentUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "인증되지 않은 사용자입니다."); } + + String currentUserEmail = authentication.getName(); + Optional currentUserOpt = userService.findByEmail(currentUserEmail); + + if (currentUserOpt.isEmpty()) { + throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "사용자를 찾을 수 없습니다."); + } + + User currentUser = currentUserOpt.get(); + Long userId = currentUser.getUserId(); + + // Long을 Integer로 변환 (기존 코드와의 호환성을 위해) + return userId.intValue(); } } From 921f4cf8cf571dda83057b66b24a7ed9964dd8fa Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 17:31:17 +0900 Subject: [PATCH 08/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=A1=B0=ED=9A=8C=20Dto=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dateRequestDto.java => HomeCreateDTO.java} | 52 ++++++++++------ .../domain/home/dto/HomeResponseDTO.java | 61 +++++++++++++++++++ .../scoula/domain/home/dto/HomeSearchDTO.java | 60 ++++++++++++++++++ 3 files changed, 155 insertions(+), 18 deletions(-) rename src/main/java/org/scoula/domain/home/dto/{request/HomeUpdateRequestDto.java => HomeCreateDTO.java} (54%) create mode 100644 src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java create mode 100644 src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java diff --git a/src/main/java/org/scoula/domain/home/dto/request/HomeUpdateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java similarity index 54% rename from src/main/java/org/scoula/domain/home/dto/request/HomeUpdateRequestDto.java rename to src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java index 03ddba31..a388bfae 100644 --- a/src/main/java/org/scoula/domain/home/dto/request/HomeUpdateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java @@ -1,11 +1,13 @@ -package org.scoula.domain.home.dto.request; +package org.scoula.domain.home.dto; import java.time.LocalDate; import java.util.List; +import javax.validation.constraints.*; + +import org.scoula.domain.home.enums.HomeDirection; import org.scoula.domain.home.enums.LeaseType; import org.scoula.domain.home.enums.ResidenceType; -import org.scoula.domain.home.vo.HomeRegisterVO; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,42 +15,56 @@ import lombok.NoArgsConstructor; @Data -@Builder @NoArgsConstructor @AllArgsConstructor -public class HomeUpdateRequestDto { - - private Long homeId; - private String userName; +@Builder +public class HomeCreateDTO { private String addr1; + private String addr2; private ResidenceType residenceType; + private LeaseType leaseType; private Integer depositPrice; + private Integer monthlyRent; - private Integer maintenanceFee; + + private Integer maintenaceFee; + + private Integer roomCnt; private Float supplyArea; + private Float exclusiveArea; - private String homeFloor; private LocalDate buildDate; + + private Integer homeFloor; + private Integer buildingTotalFloors; + + private HomeDirection homeDirection; + + private Integer bathroomCnt; + private Boolean isPet; - private Boolean isParkingAvailable; + private Boolean isParking; - private Integer roomCnt; - private Integer bathroomCount; - private String homeDirection; - private LocalDate moveInDate; + private List facilityItemIds; + + private List maintenanceFees; private List imageUrls; - private List options; - private List facilityItemIds; - // ✅ 관리비 항목 추가 - private List maintenanceFeeItems; + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MaintenanceFeeDTO { + private Integer maintenanceId; + private Integer fee; + } } diff --git a/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java new file mode 100644 index 00000000..a2fbd902 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java @@ -0,0 +1,61 @@ +package org.scoula.domain.home.dto; + +import java.time.LocalDate; +import java.util.List; + +import org.scoula.domain.home.enums.HomeDirection; +import org.scoula.domain.home.enums.HomeStatus; +import org.scoula.domain.home.enums.LeaseType; +import org.scoula.domain.home.enums.ResidenceType; +import org.scoula.domain.home.vo.FacilityItem; +import org.scoula.domain.home.vo.HomeMaintenanceFeeVO; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeResponseDTO { + + private Integer homeId; + private Integer userId; + private String userName; + + private String addr1; + private String addr2; + + private ResidenceType residenceType; + private LeaseType leaseType; + private Integer depositPrice; + private Integer monthlyRent; + private Integer maintenaceFee; + private HomeStatus homeStatus; + + private Integer viewCnt; + private Integer likeCnt; + private Integer chatCnt; + + private Integer roomCnt; + private Float supplyArea; + private Float exclusiveArea; + + private LocalDate buildDate; + private Integer homeFloor; + private Integer buildingTotalFloors; + private HomeDirection homeDirection; + private Integer bathroomCnt; + private Boolean isPet; + private Boolean isParking; + + private List facilities; + + private List maintenanceFees; + + private List imageUrls; + private LocalDate createdAt; + private LocalDate updatedAt; +} diff --git a/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java new file mode 100644 index 00000000..5eb894ab --- /dev/null +++ b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java @@ -0,0 +1,60 @@ +package org.scoula.domain.home.dto; + +import org.scoula.domain.home.enums.HomeDirection; +import org.scoula.domain.home.enums.HomeStatus; +import org.scoula.domain.home.enums.LeaseType; +import org.scoula.domain.home.enums.ResidenceType; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeSearchDTO { + + private ResidenceType residenceType; + private LeaseType leaseType; + private HomeStatus homeStatus; + private HomeDirection homeDirection; + + private Integer minDepositPrice; + private Integer maxDepositPrice; + private Integer minMonthlyRent; + private Integer maxMonthlyRent; + private Integer maxMaintenanceFee; + + private Float minSupplyArea; + private Float maxSupplyArea; + private Integer minRoomCnt; + private Integer maxRoomCnt; + + // 층수 범위 + private Integer minFloor; + private Integer maxFloor; + + // 기타 조건 + private Boolean isPet; + private Boolean isParking; + private String addr1; // 주소 검색 + + // 페이징 + private Integer page = 1; + private Integer size = 20; + + // 정렬 + private String sortBy = "createdAt"; // createdAt, price, viewCnt, likeCnt + private String sortDirection = "DESC"; // ASC, DESC + + // 페이징 계산용 메서드 + public int getOffset() { + return (page - 1) * size; + } + + public int getLimit() { + return size; + } +} From cebbf8439cd92547df4a0c134c436d5f2addc378 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 17:31:32 +0900 Subject: [PATCH 09/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=A1=B0=ED=9A=8C=20VO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/enums/HomeStatus.java | 2 +- .../domain/home/vo/FacilityCategory.java | 14 +++++++ .../scoula/domain/home/vo/FacilityItem.java | 15 ++++++++ .../scoula/domain/home/vo/HomeDetailVO.java | 25 +++++++++++++ .../scoula/domain/home/vo/HomeFacilityVO.java | 14 +++++++ .../scoula/domain/home/vo/HomeImageVO.java | 15 ++++++++ .../org/scoula/domain/home/vo/HomeLikeVO.java | 17 +++++++++ .../domain/home/vo/HomeMaintenanceFeeVO.java | 15 ++++++++ .../org/scoula/domain/home/vo/HomeVO.java | 37 +++++++++++++++++++ 9 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/scoula/domain/home/vo/FacilityCategory.java create mode 100644 src/main/java/org/scoula/domain/home/vo/FacilityItem.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeFacilityVO.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeImageVO.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeLikeVO.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeMaintenanceFeeVO.java create mode 100644 src/main/java/org/scoula/domain/home/vo/HomeVO.java diff --git a/src/main/java/org/scoula/domain/home/enums/HomeStatus.java b/src/main/java/org/scoula/domain/home/enums/HomeStatus.java index fb952ca0..81c96639 100644 --- a/src/main/java/org/scoula/domain/home/enums/HomeStatus.java +++ b/src/main/java/org/scoula/domain/home/enums/HomeStatus.java @@ -6,7 +6,7 @@ public enum HomeStatus { AVAILABLE("입주가능"), RESERVED("예약중"), - SOLD("계약완료"); + CONTRACTED("계약완료"); private final String description; diff --git a/src/main/java/org/scoula/domain/home/vo/FacilityCategory.java b/src/main/java/org/scoula/domain/home/vo/FacilityCategory.java new file mode 100644 index 00000000..30c894b5 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/FacilityCategory.java @@ -0,0 +1,14 @@ +package org.scoula.domain.home.vo; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class FacilityCategory { + private Integer categoryId; + private String categoryType; +} diff --git a/src/main/java/org/scoula/domain/home/vo/FacilityItem.java b/src/main/java/org/scoula/domain/home/vo/FacilityItem.java new file mode 100644 index 00000000..4e4d34e5 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/FacilityItem.java @@ -0,0 +1,15 @@ +package org.scoula.domain.home.vo; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class FacilityItem { + private Integer itemId; + private Integer categoryId; + private String itemName; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java new file mode 100644 index 00000000..e67b0568 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java @@ -0,0 +1,25 @@ +package org.scoula.domain.home.vo; + +import java.time.LocalDate; + +import org.scoula.domain.home.enums.HomeDirection; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeDetailVO { + private Integer homeDetailId; + private Integer homeId; + private LocalDate buildDate; + private Integer homeFloor; + private Integer buildingTotalFloors; + private HomeDirection homeDirection; + private Integer bathroomCnt; + private Boolean isPet; + private Boolean isParking; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeFacilityVO.java b/src/main/java/org/scoula/domain/home/vo/HomeFacilityVO.java new file mode 100644 index 00000000..8655163f --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeFacilityVO.java @@ -0,0 +1,14 @@ +package org.scoula.domain.home.vo; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeFacilityVO { + private Integer homeDetailId; + private Integer itemId; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java b/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java new file mode 100644 index 00000000..856babd5 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java @@ -0,0 +1,15 @@ +package org.scoula.domain.home.vo; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeImageVO { + private Integer imageId; + private Integer homeId; + private String ImageUrl; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeLikeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeLikeVO.java new file mode 100644 index 00000000..c9aca9ed --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeLikeVO.java @@ -0,0 +1,17 @@ +package org.scoula.domain.home.vo; + +import java.time.LocalDate; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeLikeVO { + private Integer userId; + private Integer homeId; + private LocalDate likedAt; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeMaintenanceFeeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeMaintenanceFeeVO.java new file mode 100644 index 00000000..0a2f4b82 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeMaintenanceFeeVO.java @@ -0,0 +1,15 @@ +package org.scoula.domain.home.vo; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeMaintenanceFeeVO { + private Integer homeId; + private Integer maintenanceId; + private Integer fee; +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeVO.java new file mode 100644 index 00000000..3caeb181 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/vo/HomeVO.java @@ -0,0 +1,37 @@ +package org.scoula.domain.home.vo; + +import java.time.LocalDate; + +import org.scoula.domain.home.enums.HomeStatus; +import org.scoula.domain.home.enums.LeaseType; +import org.scoula.domain.home.enums.ResidenceType; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HomeVO { + private Integer homeId; + private Integer userId; + private String addr1; + private String addr2; + private ResidenceType residenceType; + private LeaseType leaseType; + private Integer depositPrice; + private Integer monthlyRent; + private Integer maintenaceFee; + private HomeStatus homeStatus; + private Integer viewCnt; + private Integer likeCnt; + private Integer chatCnt; + private Integer roomCnt; + private Float supplyArea; + private LocalDate createdAt; + private LocalDate updatedAt; + private String userName; + private Float exclusiveArea; +} From 21bfcc2a7cc27ad58c5525287d963dc648d0e9a9 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Mon, 11 Aug 2025 17:31:38 +0900 Subject: [PATCH 10/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=A1=B0=ED=9A=8C=20Mapper=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/mapper/HomeMapper.java | 129 ++-- .../scoula/domain/home/mapper/HomeMapper.xml | 576 +++++++++--------- 2 files changed, 359 insertions(+), 346 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java b/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java index c75433fd..dc47787c 100644 --- a/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java +++ b/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java @@ -1,84 +1,105 @@ package org.scoula.domain.home.mapper; import java.util.List; -import java.util.Map; -import java.util.Optional; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import org.scoula.domain.home.dto.response.FacilityResponseDto; -import org.scoula.domain.home.dto.response.MaintenanceFeeItemResponseDto; -import org.scoula.domain.home.vo.HomeRegisterVO; -import org.scoula.domain.home.vo.HomeReportVO; -import org.scoula.global.common.dto.PageRequest; +import org.scoula.domain.home.dto.HomeSearchDTO; +import org.scoula.domain.home.vo.*; @Mapper public interface HomeMapper { - /** 사용자 이름 조회 (identity_verification 테이블에서) */ - String findUserNameById(@Param("userId") Long userId); + // === 기본 매물 관리 === + // 매물 등록 + int insertHome(HomeVO home); - /** 매물 전체 조회 (페이징 포함) */ - List findHomes(@Param("offset") int offset, @Param("size") int size); + // 매물 상세 정보 등록 + int insertHomeDetail(HomeDetailVO homeDetail); - /** 매물 총 개수 조회 */ - long countHomes(@Param("pageRequest") PageRequest pageRequest); + // 매물 조회 + HomeVO selectHomeById(@Param("homeId") Integer homeId); - /** 매물 단건 조회 */ - Optional findHomeById(@Param("homeId") Long homeId); + // 매물 상세 조회 + HomeDetailVO selectHomeDetailByHomeId(@Param("homeId") Integer homeId); - List findHomeFacilities(@Param("homeId") Long homeId); + // 매물 목록 조회 (페이징) + List selectHomeList(@Param("offset") int offset, @Param("limit") int limit); - List findHomeMaintenanceItemsByHomeId( - @Param("homeId") Long homeId); + // 매물 검색 (조건별) + List selectHomeListByCondition(@Param("search") HomeSearchDTO searchDTO); - /** 특정 매물 이미지 URL 리스트 조회 추가 */ - List findHomeImagesByHomeId(@Param("homeId") Long homeId); + // 매물 총 개수 + int selectHomeCount(); - /** 매물 등록 */ - void insertHome( - @Param("userId") Long userId, - @Param("userName") String userName, - @Param("home") HomeRegisterVO home); + // 조건별 매물 총 개수 + int selectHomeCountByCondition(@Param("search") HomeSearchDTO searchDTO); - /** 매물 수정 */ - void updateHome(@Param("home") HomeRegisterVO home); + // 매물 수정 + int updateHome(HomeVO home); - /** 매물 삭제 */ - void deleteHome(@Param("homeId") Long homeId); + // 매물 상세 수정 + int updateHomeDetail(HomeDetailVO homeDetail); - /** 찜 추가 */ - void insertHomeLike(@Param("userId") Long userId, @Param("homeId") Long homeId); + // 매물 삭제 + int deleteHome(@Param("homeId") Integer homeId); - /** 찜 제거 */ - void deleteLike(@Param("userId") Long userId, @Param("homeId") Long homeId); + // 매물 상태 변경 + int updateHomeStatus(@Param("homeId") Integer homeId, @Param("status") String status); - /** 찜한 매물 목록 조회 */ - List findLikedHomes(@Param("userId") Long userId); + // 조회수 증가 + int incrementViewCount(@Param("homeId") Integer homeId); - /** 조회수 증가 */ - void incrementViewCount(@Param("homeId") Long homeId); + // 사용자별 매물 조회 + List selectHomeListByUserId(@Param("userId") Integer userId); - /** 내가 등록한 매물 리스트 & 개수 */ - // List findMyHomes( - // @Param("userId") Long userId, @Param("offset") int offset, @Param("size") int - // size); - // - // long countMyHomes(@Param("userId") Long userId); + // === 이미지 관리 === + // 매물 이미지 등록 + int insertHomeImage(HomeImageVO homeImage); - /** 상세 정보 등록 */ - void insertHomeDetail(HomeRegisterVO vo); + // 매물 이미지 조회 + List selectHomeImagesByHomeId(@Param("homeId") Integer homeId); - /** 옵션 정보 등록 */ - void insertHomeFacilities(Map param); + // 매물 이미지 삭제 + int deleteHomeImagesByHomeId(@Param("homeId") Integer homeId); - /** 이미지 등록 */ - void insertHomeImages(Map param); + // === 편의시설 관리 === + // 매물 편의시설 등록 + int insertHomeFacility(HomeFacilityVO homeFacility); - /** 관리비 항목 등록 */ - void insertHomeMaintenanceFees( - @Param("homeId") Long homeId, @Param("fees") Map fees); + // 매물 편의시설 조회 + List selectHomeFacilitiesByHomeDetailId( + @Param("homeDetailId") Integer homeDetailId); - /** 신고 정보 등록 */ - void insertHomeReport(@Param("report") HomeReportVO report); + // 매물 편의시설 삭제 + int deleteHomeFacilitiesByHomeDetailId(@Param("homeDetailId") Integer homeDetailId); + + // 편의시설 카테고리 조회 + List selectFacilityCategories(); + + // 편의시설 아이템 조회 + List selectFacilityItemsByCategoryId(@Param("categoryId") Integer categoryId); + + // === 관리비 관리 === + // 매물 관리비 등록 + int insertHomeMaintenanceFee(HomeMaintenanceFeeVO maintenanceFee); + + // 매물 관리비 조회 + List selectHomeMaintenanceFeesByHomeId(@Param("homeId") Integer homeId); + + // 매물 관리비 삭제 + int deleteHomeMaintenanceFeesByHomeId(@Param("homeId") Integer homeId); + + // === 찜 관리 === + // 찜 등록 + int insertHomeLike(HomeLikeVO homeLike); + + // 찜 삭제 + int deleteHomeLike(@Param("userId") Integer userId, @Param("homeId") Integer homeId); + + // 찜 여부 확인 + int selectHomeLikeExists(@Param("userId") Integer userId, @Param("homeId") Integer homeId); + + // 사용자 찜 목록 조회 + List selectHomeLikesByUserId(@Param("userId") Integer userId); } diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index 059925b5..f67f73f2 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -1,356 +1,348 @@ - - + + - - - + + INSERT INTO home ( - user_id, user_name, - addr1, addr2, residence_type, lease_type, deposit_price, - monthly_rent, maintenance_fee, supply_area, exclusive_area, home_status, - view_cnt, like_cnt, chat_cnt, report_cnt, - room_cnt, created_at, updated_at + user_id, addr1, addr2, residence_type, lease_type, + deposit_price, monthly_rent, maintenance_fee, home_status, + view_cnt, like_cnt, chat_cnt, room_cnt, supply_area, exclusive_area, + created_at, updated_at ) VALUES ( - #{userId}, #{userName}, - #{home.addr1}, #{home.addr2}, #{home.residenceType}, #{home.leaseType}, #{home.depositPrice}, - #{home.monthlyRent}, #{home.maintenanceFee}, #{home.supplyArea}, #{home.exclusiveArea}, #{home.homeStatus}, - 0, 0, 0, 0, - #{home.roomCnt}, NOW(), NOW() + #{userId}, #{addr1}, #{addr2}, #{residenceType}, #{leaseType}, + #{depositPrice}, #{monthlyRent}, #{maintenaceFee}, #{homeStatus}, + #{viewCnt}, #{likeCnt}, #{chatCnt}, #{roomCnt}, #{supplyArea}, #{exclusiveArea}, + #{createdAt}, #{updatedAt} ) - + + INSERT INTO home_detail ( - home_id, - home_detail_id, - build_date, - home_floor, - building_total_floors, - home_direction, - bathroom_count, - is_pet, - is_parking_available + home_id, build_date, home_floor, building_total_floors, + home_direction, bathroom_count, is_pet, is_parking_available ) VALUES ( - #{homeId}, - #{homeDetailId}, - #{buildDate}, - #{homeFloor}, - #{buildingTotalFloors}, - #{homeDirection}, - #{bathroomCount}, - #{isPet}, - #{isParkingAvailable} + #{homeId}, #{buildDate}, #{homeFloor}, #{buildingTotalFloors}, + #{homeDirection}, #{bathroomCnt}, #{isPet}, #{isParking} ) - - INSERT INTO home_facility (home_detail_id, item_id) - VALUES - - (#{homeDetailId}, #{itemId}) - - - - + + INSERT INTO home_image (home_id, image_url) - VALUES - - (#{homeId}, #{imageUrl}) - + VALUES (#{homeId}, #{ImageUrl}) - - INSERT INTO home_like (user_id, home_id, liked_at) - VALUES (#{userId}, #{homeId}, NOW()) + + + INSERT INTO home_facility (home_detail_id, item_id) + VALUES (#{homeDetailId}, #{itemId}) - + + INSERT INTO home_maintenance_fee (home_id, maintenance_id, fee) - VALUES - - (#{homeId}, #{feeItem.key}, #{feeItem.value}) - + VALUES (#{homeId}, #{maintenanceId}, #{fee}) - - INSERT INTO home_report ( - report_id, user_id, home_id, report_reason, report_at, report_status - ) VALUES ( - #{reportId}, - #{userId}, - #{homeId}, - #{reportReason}, - #{reportAt}, - #{reportStatus} - ) + + + INSERT INTO home_like (user_id, home_id, liked_at) + VALUES (#{userId}, #{homeId}, #{likedAt}) - SELECT - h.home_id AS homeId, - h.user_id AS userId, - h.user_name AS userName, - h.addr1, - h.addr2, - h.residence_type AS residenceType, - h.lease_type AS leaseType, - h.deposit_price AS depositPrice, - h.monthly_rent AS monthlyRent, - h.maintenance_fee AS maintenanceFee, - h.supply_area AS supplyArea, - h.exclusive_area AS exclusiveArea, - h.room_cnt AS roomCnt, - h.home_status AS homeStatus, - h.view_cnt AS viewCnt, - h.like_cnt AS likeCnt, - h.chat_cnt AS chatCnt, - h.report_cnt AS reportCnt, - h.created_at AS createdAt, - h.updated_at AS updatedAt, - d.home_detail_id AS homeDetailId, - d.build_date AS buildDate, - d.home_floor AS homeFloor, - d.building_total_floors AS buildingTotalFloors, - d.home_direction AS homeDirection, - d.bathroom_count AS bathroomCount, - d.is_pet AS isPet, - d.is_parking_available AS isParkingAvailable, - hi.image_id AS imageId, - hi.image_url AS imageUrl + h.home_id as homeId, h.user_id as userId, h.addr1, h.addr2, + h.residence_type as residenceType, h.lease_type as leaseType, + h.deposit_price as depositPrice, h.monthly_rent as monthlyRent, + h.maintenance_fee as maintenaceFee, h.home_status as homeStatus, + h.view_cnt as viewCnt, h.like_cnt as likeCnt, h.chat_cnt as chatCnt, + h.room_cnt as roomCnt, h.supply_area as supplyArea, h.exclusive_area as exclusiveArea, + h.created_at as createdAt, h.updated_at as updatedAt, u.nickname as userName FROM home h - LEFT JOIN home_detail d ON h.home_id = d.home_id - LEFT JOIN ( - SELECT home_id, MIN(image_id) AS image_id, MIN(image_url) AS image_url - FROM home_image - GROUP BY home_id - ) hi ON h.home_id = hi.home_id - ORDER BY h.created_at DESC - LIMIT #{offset}, #{size} + LEFT JOIN user u ON h.user_id = u.user_id + WHERE h.home_id = #{homeId} - + SELECT + home_detail_id, home_id, build_date, home_floor, building_total_floors, + home_direction, bathroom_count as bathroomCnt, is_pet as isPet, is_parking_available as isParking + FROM home_detail + WHERE home_id = #{homeId} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SELECT - h.*, - d.*, - hi.image_url, - hf.maintenance_id, - hf.fee, - fi.item_name + h.home_id as homeId, h.user_id as userId, h.addr1, h.addr2, + h.residence_type as residenceType, h.lease_type as leaseType, + h.deposit_price as depositPrice, h.monthly_rent as monthlyRent, + h.maintenance_fee as maintenaceFee, h.home_status as homeStatus, + h.view_cnt as viewCnt, h.like_cnt as likeCnt, h.chat_cnt as chatCnt, + h.room_cnt as roomCnt, h.supply_area as supplyArea, h.exclusive_area as exclusiveArea, + h.created_at as createdAt, h.updated_at as updatedAt FROM home h - LEFT JOIN home_detail d ON h.home_id = d.home_id - LEFT JOIN home_image hi ON h.home_id = hi.home_id - LEFT JOIN home_maintenance_fee hf ON h.home_id = hf.home_id - LEFT JOIN home_facility hfl ON d.home_detail_id = hfl.home_detail_id LEFT JOIN facility_item fi ON hfl.item_id = fi.item_id - WHERE h.home_id = #{homeId} - ORDER BY hi.image_id, hf.maintenance_id, fi.item_id + WHERE h.home_status = 'AVAILABLE' AND EXISTS(select * from home_identity_verification as hiv where h.home_id = hiv.home_id) + ORDER BY h.created_at DESC + LIMIT #{limit} OFFSET #{offset} - SELECT - fi.item_id AS itemId, - fi.item_name AS itemName, - fc.category_id AS categoryId, - fc.category_type AS categoryType - FROM home_facility hf - JOIN home_detail hd ON hf.home_detail_id = hd.home_detail_id - JOIN facility_item fi ON hf.item_id = fi.item_id - JOIN facility_category fc ON fi.category_id = fc.category_id - WHERE hd.home_id = #{homeId} + h.home_id as homeId, h.user_id as userId, h.addr1, h.addr2, + h.residence_type as residenceType, h.lease_type as leaseType, + h.deposit_price as depositPrice, h.monthly_rent as monthlyRent, + h.maintenance_fee as maintenaceFee, h.home_status as homeStatus, + h.view_cnt as viewCnt, h.like_cnt as likeCnt, h.chat_cnt as chatCnt, + h.room_cnt as roomCnt, h.supply_area as supplyArea, h.exclusive_area as exclusiveArea, + h.created_at as createdAt, h.updated_at as updatedAt + FROM home h + WHERE 1=1 + + AND h.residence_type = #{search.residenceType} + + + AND h.lease_type = #{search.leaseType} + + + AND h.home_status = #{search.homeStatus} + + + AND h.deposit_price >= #{search.minDepositPrice} + + + AND h.deposit_price <= #{search.maxDepositPrice} + + + AND h.monthly_rent >= #{search.minMonthlyRent} + + + AND h.monthly_rent <= #{search.maxMonthlyRent} + + + AND h.maintenance_fee <= #{search.maxMaintenanceFee} + + + AND h.supply_area >= #{search.minSupplyArea} + + + AND h.supply_area <= #{search.maxSupplyArea} + + + AND h.room_cnt >= #{search.minRoomCnt} + + + AND h.room_cnt <= #{search.maxRoomCnt} + + + AND h.addr1 LIKE CONCAT('%', #{search.addr1}, '%') + + ORDER BY h.created_at DESC + LIMIT #{search.limit} OFFSET #{search.offset} - + SELECT image_id, home_id, image_url as ImageUrl FROM home_image WHERE home_id = #{homeId} ORDER BY image_id - + SELECT fi.item_id, fi.category_id, fi.item_name + FROM home_facility hf + JOIN facility_item fi ON hf.item_id = fi.item_id + WHERE hf.home_detail_id = #{homeDetailId} - - UPDATE home - SET - addr1 = #{addr1}, - addr2 = #{addr2}, - residence_type = #{residenceType}, - lease_type = #{leaseType}, - deposit_price = #{depositPrice}, - monthly_rent = #{monthlyRent}, - maintenance_fee = #{maintenanceFee}, - supply_area = #{supplyArea}, - exclusive_area = #{exclusiveArea}, - room_cnt = #{roomCnt}, - updated_at = NOW() + + - - DELETE FROM home - WHERE home_id = #{homeId} - + + - + SELECT item_id, category_id, item_name + FROM facility_item + WHERE category_id = #{categoryId} + ORDER BY item_id - + + UPDATE home SET view_cnt = view_cnt + 1 WHERE home_id = #{homeId} - + + + + + + + + + + + DELETE FROM home_like WHERE user_id = #{userId} AND home_id = #{homeId} - SELECT - h.home_id AS homeId, - h.user_id AS userId, - h.user_name AS userName, - h.addr1, - h.addr2, - h.residence_type AS residenceType, - h.lease_type AS leaseType, - h.deposit_price AS depositPrice, - h.monthly_rent AS monthlyRent, - h.maintenance_fee AS maintenanceFee, - h.supply_area AS supplyArea, - h.exclusive_area AS exclusiveArea, - h.room_cnt AS roomCnt, - h.home_status AS homeStatus, - h.view_cnt AS viewCnt, - h.like_cnt AS likeCnt, - h.chat_cnt AS chatCnt, - h.report_cnt AS reportCnt, - h.created_at AS createdAt, - h.updated_at AS updatedAt, - d.home_detail_id AS homeDetailId, - d.build_date AS buildDate, - d.home_floor AS homeFloor, - d.building_total_floors AS buildingTotalFloors, - d.home_direction AS homeDirection, - d.bathroom_count AS bathroomCount, - d.is_pet AS isPet, - d.is_parking_available AS isParkingAvailable, - hi.image_id AS imageId, - hi.image_url AS imageUrl + h.home_id as homeId, h.user_id as userId, h.addr1, h.addr2, + h.residence_type as residenceType, h.lease_type as leaseType, + h.deposit_price as depositPrice, h.monthly_rent as monthlyRent, + h.maintenance_fee as maintenaceFee, h.home_status as homeStatus, + h.view_cnt as viewCnt, h.like_cnt as likeCnt, h.chat_cnt as chatCnt, + h.room_cnt as roomCnt, h.supply_area as supplyArea, h.exclusive_area as exclusiveArea, + h.created_at as createdAt, h.updated_at as updatedAt + FROM home h + JOIN home_like hl ON h.home_id = hl.home_id + WHERE hl.user_id = #{userId} + ORDER BY hl.liked_at DESC + + + + - + + + UPDATE home SET + addr1 = #{addr1}, + addr2 = #{addr2}, + residence_type = #{residenceType}, + lease_type = #{leaseType}, + deposit_price = #{depositPrice}, + monthly_rent = #{monthlyRent}, + maintenance_fee = #{maintenaceFee}, + room_cnt = #{roomCnt}, + supply_area = #{supplyArea}, + exclusive_area = #{exclusiveArea}, + updated_at = #{updatedAt} + WHERE home_id = #{homeId} + + + + + UPDATE home_detail SET + build_date = #{buildDate}, + home_floor = #{homeFloor}, + building_total_floors = #{buildingTotalFloors}, + home_direction = #{homeDirection}, + bathroom_count = #{bathroomCnt}, + is_pet = #{isPet}, + is_parking_available = #{isParking} + WHERE home_detail_id = #{homeDetailId} + + + + + UPDATE home + SET home_status = #{status} + WHERE home_id = #{homeId} + + + + + DELETE FROM home WHERE home_id = #{homeId} + + + + + DELETE FROM home_image WHERE home_id = #{homeId} + + + + + DELETE FROM home_maintenance_fee WHERE home_id = #{homeId} + + + + + DELETE FROM home_facility WHERE home_detail_id = #{homeDetailId} + + \ No newline at end of file From 4bfab612c9d0ddc7f7d39cb72d626b7f611bf6d6 Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:47:45 +0900 Subject: [PATCH 11/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95=20DTO=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeCreateRequestDto, HomeUpdateRequestDto 신규 생성 - HomeCreateDTO에서 imageUrls 필드 제거 (파일 업로드만 지원) - HomeImageVO 필드명 수정 (ImageUrl → imageUrl) - Swagger 어노테이션 추가 및 예제값 설정 --- .../scoula/domain/home/dto/HomeCreateDTO.java | 2 - .../domain/home/dto/HomeCreateRequestDto.java | 106 +++++++++++++++ .../domain/home/dto/HomeUpdateRequestDto.java | 128 ++++++++++++++++++ .../scoula/domain/home/vo/HomeImageVO.java | 2 +- 4 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java create mode 100644 src/main/java/org/scoula/domain/home/dto/HomeUpdateRequestDto.java diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java index a388bfae..4998f60f 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java @@ -57,8 +57,6 @@ public class HomeCreateDTO { private List maintenanceFees; - private List imageUrls; - @Data @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java new file mode 100644 index 00000000..de9a9c26 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java @@ -0,0 +1,106 @@ +package org.scoula.domain.home.dto; + +import java.time.LocalDate; +import java.util.List; + +import javax.validation.constraints.*; + +import org.scoula.domain.home.enums.HomeDirection; +import org.scoula.domain.home.enums.LeaseType; +import org.scoula.domain.home.enums.ResidenceType; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "매물 등록 요청 DTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeCreateRequestDto { + + @ApiModelProperty(value = "시/도 및 시/군/구", example = "서울특별시 강남구", required = true) + private String addr1; + + @ApiModelProperty(value = "상세 주소", example = "테헤란로 123 아파트 101동 501호") + private String addr2; + + @ApiModelProperty( + value = "거주 유형", + example = "APARTMENT", + required = true, + allowableValues = "APARTMENT, VILLA, ONEROOM, OFFICETEL, HOUSE") + private ResidenceType residenceType; + + @ApiModelProperty( + value = "임대 유형", + example = "WOLSE", + required = true, + allowableValues = "JEONSE, WOLSE") + private LeaseType leaseType; + + @ApiModelProperty(value = "보증금 (원)", example = "50000000", required = true) + private Integer depositPrice; + + @ApiModelProperty(value = "월세 (원)", example = "1000000", notes = "전세인 경우 0") + private Integer monthlyRent; + + @ApiModelProperty(value = "관리비 (원)", example = "150000") + private Integer maintenanceFee; + + @ApiModelProperty(value = "방 개수", example = "3") + private Integer roomCnt; + + @ApiModelProperty(value = "공급 면적 (㎡)", example = "84.5") + private Float supplyArea; + + @ApiModelProperty(value = "전용 면적 (㎡)", example = "59.8") + private Float exclusiveArea; + + @ApiModelProperty(value = "준공일", example = "2020-03-15") + private LocalDate buildDate; + + @ApiModelProperty(value = "해당 층수", example = "5") + private Integer homeFloor; + + @ApiModelProperty(value = "건물 총 층수", example = "15") + private Integer buildingTotalFloors; + + @ApiModelProperty(value = "집 방향", example = "S", allowableValues = "E, W, S, N, SE, SW, NE, NW") + private HomeDirection homeDirection; + + @ApiModelProperty(value = "욕실 개수", example = "2") + private Integer bathroomCnt; + + @ApiModelProperty(value = "반려동물 가능 여부", example = "true") + private Boolean isPet; + + @ApiModelProperty(value = "주차 가능 여부", example = "true") + private Boolean isParking; + + @ApiModelProperty(value = "편의시설 ID 목록", example = "[1, 2, 3, 5, 8]") + private List facilityItemIds; + + @ApiModelProperty(value = "관리비 항목 목록") + private List maintenanceFees; + + @ApiModel(description = "관리비 항목 DTO") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MaintenanceFeeDTO { + @ApiModelProperty( + value = "관리비 항목 ID", + example = "1", + notes = "1: 전기세, 2: 수도세, 3: 가스비, 4: 인터넷, 5: TV") + private Integer maintenanceId; + + @ApiModelProperty(value = "관리비 금액 (원)", example = "50000") + private Integer fee; + } +} diff --git a/src/main/java/org/scoula/domain/home/dto/HomeUpdateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeUpdateRequestDto.java new file mode 100644 index 00000000..8cc33e52 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/dto/HomeUpdateRequestDto.java @@ -0,0 +1,128 @@ +package org.scoula.domain.home.dto; + +import java.time.LocalDate; +import java.util.List; + +import javax.validation.constraints.*; + +import org.scoula.domain.home.enums.HomeDirection; +import org.scoula.domain.home.enums.LeaseType; +import org.scoula.domain.home.enums.ResidenceType; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "매물 수정 요청 DTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeUpdateRequestDto { + + @ApiModelProperty(value = "매물 ID", example = "123", notes = "URL 경로에서 자동으로 설정됨") + private Integer homeId; + + @ApiModelProperty(value = "시/도 및 시/군/구", example = "서울특별시 강남구", required = true) + private String addr1; + + @ApiModelProperty(value = "상세 주소", example = "테헤란로 456 빌라 201호") + private String addr2; + + @ApiModelProperty( + value = "거주 유형", + example = "VILLA", + required = true, + allowableValues = "APARTMENT, VILLA, ONEROOM, OFFICETEL, HOUSE") + private ResidenceType residenceType; + + @ApiModelProperty( + value = "임대 유형", + example = "JEONSE", + required = true, + allowableValues = "JEONSE, WOLSE") + private LeaseType leaseType; + + @ApiModelProperty(value = "보증금 (원)", example = "300000000", required = true) + private Integer depositPrice; + + @ApiModelProperty(value = "월세 (원)", example = "0", notes = "전세인 경우 0") + private Integer monthlyRent; + + @ApiModelProperty(value = "관리비 (원)", example = "100000") + private Integer maintenanceFee; + + @ApiModelProperty(value = "방 개수", example = "2") + private Integer roomCnt; + + @ApiModelProperty(value = "공급 면적 (㎡)", example = "59.7") + private Float supplyArea; + + @ApiModelProperty(value = "전용 면적 (㎡)", example = "48.3") + private Float exclusiveArea; + + @ApiModelProperty(value = "준공일", example = "2018-07-20") + private LocalDate buildDate; + + @ApiModelProperty(value = "해당 층수", example = "2") + private Integer homeFloor; + + @ApiModelProperty(value = "건물 총 층수", example = "5") + private Integer buildingTotalFloors; + + @ApiModelProperty( + value = "집 방향", + example = "SE", + allowableValues = "E, W, S, N, SE, SW, NE, NW") + private HomeDirection homeDirection; + + @ApiModelProperty(value = "욕실 개수", example = "1") + private Integer bathroomCnt; + + @ApiModelProperty(value = "반려동물 가능 여부", example = "false") + private Boolean isPet; + + @ApiModelProperty(value = "주차 가능 여부", example = "true") + private Boolean isParking; + + @ApiModelProperty(value = "편의시설 ID 목록", example = "[2, 4, 6, 7, 9]") + private List facilityItemIds; + + @ApiModelProperty(value = "관리비 항목 목록") + private List maintenanceFees; + + @ApiModelProperty( + value = "삭제할 이미지 ID 목록", + example = "[1, 3]", + notes = "삭제하려는 기존 이미지의 ID를 입력하세요") + private List deleteImageIds; + + @ApiModelProperty( + value = "기존 이미지 URL 목록", + notes = "읽기 전용, 현재 등록된 이미지를 표시하기 위한 용도", + readOnly = true) + private List existingImageUrls; + + @ApiModelProperty(hidden = true) + private List newImages; + + @ApiModel(description = "관리비 항목 DTO") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MaintenanceFeeDTO { + @ApiModelProperty( + value = "관리비 항목 ID", + example = "2", + notes = "1: 전기세, 2: 수도세, 3: 가스비, 4: 인터넷, 5: TV") + private Integer maintenanceId; + + @ApiModelProperty(value = "관리비 금액 (원)", example = "30000") + private Integer fee; + } +} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java b/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java index 856babd5..bf4f42cb 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeImageVO.java @@ -11,5 +11,5 @@ public class HomeImageVO { private Integer imageId; private Integer homeId; - private String ImageUrl; + private String imageUrl; } From b0986a0792f8ec2a8eac079f3d3ddccf8ce24d7d Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:48:02 +0900 Subject: [PATCH 12/34] =?UTF-8?q?=F0=9F=90=9B=20fix:=20HomeMapper.xml=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectHomeFacilitiesByHomeDetailId 중복 제거 - selectHomeMaintenanceFeesByHomeId 중복 제거 - selectFacilityCategories 중복 제거 - selectFacilityItemsByCategoryId 중복 제거 - incrementViewCount 중복 제거 --- .../scoula/domain/home/mapper/HomeMapper.xml | 85 +++++++++---------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index f67f73f2..b1112ba6 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -32,7 +32,7 @@ INSERT INTO home_image (home_id, image_url) - VALUES (#{homeId}, #{ImageUrl}) + VALUES (#{homeId}, #{imageUrl}) @@ -148,50 +148,6 @@ LIMIT #{search.limit} OFFSET #{search.offset} - - - - - - - - - - - - - - - - - - UPDATE home - SET view_cnt = view_cnt + 1 - WHERE home_id = #{homeId} - + SELECT image_id as imageId, home_id as homeId, image_url as imageUrl + FROM home_image + WHERE home_id = #{homeId} + + + + + + + + + + + + + + \ No newline at end of file From 64ce3d0157a0bf5ec6b480a3e2f3ba9f313017c1 Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:49:23 +0900 Subject: [PATCH 13/34] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=A7=A4=EB=AC=BC=20Controller=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=99=80=20=EA=B5=AC=ED=98=84=EB=B6=80=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeController 인터페이스 정의 - HomeControllerImpl 구현 클래스 생성 - API 정의와 비즈니스 로직 분리 - 테스트 가능성 및 유지보수성 향상 --- .../home/controller/HomeController.java | 220 +++--------- .../home/controller/HomeControllerImpl.java | 331 ++++++++++++++++++ 2 files changed, 373 insertions(+), 178 deletions(-) create mode 100644 src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index 1829c740..3eafbf88 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -1,238 +1,102 @@ package org.scoula.domain.home.controller; import java.util.List; -import java.util.Optional; import javax.validation.Valid; -import org.scoula.domain.home.dto.HomeCreateDTO; +import org.scoula.domain.home.dto.HomeCreateRequestDto; import org.scoula.domain.home.dto.HomeResponseDTO; import org.scoula.domain.home.dto.HomeSearchDTO; -import org.scoula.domain.home.service.HomeService; +import org.scoula.domain.home.dto.HomeUpdateRequestDto; import org.scoula.domain.home.vo.FacilityCategory; import org.scoula.domain.home.vo.FacilityItem; -import org.scoula.domain.user.service.UserServiceInterface; -import org.scoula.domain.user.vo.User; import org.scoula.global.common.dto.ApiResponse; -import org.scoula.global.common.exception.BusinessException; -import org.scoula.global.common.exception.CommonErrorCode; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j -@RestController -@RequestMapping("/api/homes") -@RequiredArgsConstructor @Api(tags = "매물 관리", description = "매물 등록, 조회, 수정, 삭제 API") -public class HomeController { - - private final HomeService homeService; - private final UserServiceInterface userService; - - @ApiOperation(value = "매물 등록", notes = "새로운 매물을 등록합니다.") - @PostMapping - public ResponseEntity> createHome( - @Valid @RequestBody HomeCreateDTO createDTO, Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("매물 등록 요청: userId={}, residenceType={}", userId, createDTO.getResidenceType()); - - Integer homeId = homeService.createHome(createDTO, null, userId); +@RequestMapping("/api/homes") +public interface HomeController { - return ResponseEntity.ok(ApiResponse.success(homeId, "매물이 성공적으로 등록되었습니다.")); - } + @ApiOperation(value = "매물 등록", notes = "새로운 매물을 등록합니다. 이미지 파일을 함께 업로드할 수 있습니다.") + @PostMapping(consumes = "multipart/form-data") + ResponseEntity> createHome( + @Valid @ModelAttribute HomeCreateRequestDto requestDto, + @ApiParam(value = "매물 이미지 파일들 (최대 5개)") + @RequestParam(value = "images", required = false) + List images, + Authentication authentication); @ApiOperation(value = "매물 상세 조회", notes = "매물 ID로 상세 정보를 조회합니다.") @GetMapping("/{homeId}") - public ResponseEntity> getHome( - @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId) { - - log.info("매물 상세 조회 요청: homeId={}", homeId); - - HomeResponseDTO home = homeService.getHome(homeId); - - return ResponseEntity.ok(ApiResponse.success(home, "매물 조회가 완료되었습니다.")); - } + ResponseEntity> getHome( + @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId); @ApiOperation(value = "매물 목록 조회", notes = "페이징된 매물 목록을 조회합니다.") @GetMapping - public ResponseEntity>> getHomeList( + ResponseEntity>> getHomeList( @ApiParam(value = "페이지 번호 (1부터 시작)", defaultValue = "1") @RequestParam(defaultValue = "1") int page, @ApiParam(value = "페이지 크기", defaultValue = "20") @RequestParam(defaultValue = "20") - int size) { - - log.info("매물 목록 조회 요청: page={}, size={}", page, size); - - List homes = homeService.getHomeList(page, size); - int totalCount = homeService.getTotalHomeCount(); - - return ResponseEntity.ok( - ApiResponse.success( - homes, String.format("매물 목록 조회가 완료되었습니다. (총 %d개)", totalCount))); - } + int size); @ApiOperation(value = "매물 검색", notes = "조건에 따라 매물을 검색합니다.") @GetMapping("/search") - public ResponseEntity>> searchHomes( - @ApiParam(value = "검색 조건") @ModelAttribute HomeSearchDTO searchDTO) { - - log.info( - "매물 검색 요청: residenceType={}, leaseType={}, addr1={}", - searchDTO.getResidenceType(), - searchDTO.getLeaseType(), - searchDTO.getAddr1()); - - List homes = homeService.searchHomes(searchDTO); - int totalCount = homeService.getHomeCountByCondition(searchDTO); - - return ResponseEntity.ok( - ApiResponse.success(homes, String.format("매물 검색이 완료되었습니다. (총 %d개)", totalCount))); - } - - @ApiOperation(value = "매물 수정", notes = "기존 매물 정보를 수정합니다.") - @PutMapping("/{homeId}") - public ResponseEntity> updateHome( - @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, - @Valid @RequestBody HomeCreateDTO updateDTO, - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("매물 수정 요청: homeId={}, userId={}", homeId, userId); - - homeService.updateHome(homeId, updateDTO, userId); - - return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 수정되었습니다.")); - } + ResponseEntity>> searchHomes( + @ApiParam(value = "검색 조건") @ModelAttribute HomeSearchDTO searchDTO); + + @ApiOperation( + value = "매물 수정", + notes = "기존 매물 정보를 수정합니다. 이미지 파일을 추가로 업로드하거나 기존 이미지를 삭제할 수 있습니다.") + @PutMapping(value = "/{homeId}", consumes = "multipart/form-data") + ResponseEntity> updateHome( + @ApiParam(value = "매물 ID", required = true, example = "123") @PathVariable + Integer homeId, + @Valid @ModelAttribute HomeUpdateRequestDto updateDto, + @ApiParam(value = "새로 추가할 이미지 파일들") @RequestParam(value = "newImages", required = false) + List newImages, + Authentication authentication); @ApiOperation(value = "매물 삭제", notes = "매물을 삭제합니다.") @DeleteMapping("/{homeId}") - public ResponseEntity> deleteHome( + ResponseEntity> deleteHome( @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("매물 삭제 요청: homeId={}, userId={}", homeId, userId); - - homeService.deleteHome(homeId, userId); - - return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 삭제되었습니다.")); - } + Authentication authentication); @ApiOperation(value = "매물 상태 변경", notes = "매물의 상태를 변경합니다.") @PatchMapping("/{homeId}/status") - public ResponseEntity> updateHomeStatus( + ResponseEntity> updateHomeStatus( @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, @ApiParam(value = "변경할 상태", required = true) @RequestParam String status, - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("매물 상태 변경 요청: homeId={}, status={}, userId={}", homeId, status, userId); - - homeService.updateHomeStatus(homeId, status, userId); - - return ResponseEntity.ok(ApiResponse.success(null, "매물 상태가 성공적으로 변경되었습니다.")); - } + Authentication authentication); @ApiOperation(value = "내 매물 목록 조회", notes = "로그인한 사용자의 매물 목록을 조회합니다.") @GetMapping("/my") - public ResponseEntity>> getMyHomes( - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("내 매물 목록 조회 요청: userId={}", userId); - - List homes = homeService.getHomesByUser(userId); - - return ResponseEntity.ok( - ApiResponse.success( - homes, String.format("내 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); - } + ResponseEntity>> getMyHomes(Authentication authentication); @ApiOperation(value = "매물 찜하기/해제", notes = "매물을 찜하거나 찜을 해제합니다.") @PostMapping("/{homeId}/like") - public ResponseEntity> toggleHomeLike( + ResponseEntity> toggleHomeLike( @ApiParam(value = "매물 ID", required = true) @PathVariable Integer homeId, - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("매물 찜 토글 요청: homeId={}, userId={}", homeId, userId); - - homeService.toggleHomeLike(userId, homeId); - - return ResponseEntity.ok(ApiResponse.success(null, "찜 상태가 변경되었습니다.")); - } + Authentication authentication); @ApiOperation(value = "찜한 매물 목록 조회", notes = "사용자가 찜한 매물 목록을 조회합니다.") @GetMapping("/likes") - public ResponseEntity>> getHomeLikes( - Authentication authentication) { - - Integer userId = getCurrentUserId(authentication); - - log.info("찜한 매물 목록 조회 요청: userId={}", userId); - - List homes = homeService.getHomeLikes(userId); - - return ResponseEntity.ok( - ApiResponse.success( - homes, String.format("찜한 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); - } + ResponseEntity>> getHomeLikes(Authentication authentication); @ApiOperation(value = "편의시설 카테고리 조회", notes = "편의시설 카테고리 목록을 조회합니다.") @GetMapping("/facilities/categories") - public ResponseEntity>> getFacilityCategories() { - - log.info("편의시설 카테고리 조회 요청"); - - List categories = homeService.getFacilityCategories(); - - return ResponseEntity.ok(ApiResponse.success(categories, "편의시설 카테고리 조회가 완료되었습니다.")); - } + ResponseEntity>> getFacilityCategories(); @ApiOperation(value = "편의시설 아이템 조회", notes = "특정 카테고리의 편의시설 아이템 목록을 조회합니다.") @GetMapping("/facilities/categories/{categoryId}/items") - public ResponseEntity>> getFacilityItems( - @ApiParam(value = "카테고리 ID", required = true) @PathVariable Integer categoryId) { - - log.info("편의시설 아이템 조회 요청: categoryId={}", categoryId); - - List items = homeService.getFacilityItemsByCategory(categoryId); - - return ResponseEntity.ok(ApiResponse.success(items, "편의시설 아이템 조회가 완료되었습니다.")); - } - - /** Authentication에서 사용자 ID를 추출하는 메서드 실제 구현시에는 JWT에서 사용자 정보를 추출 */ - private Integer getCurrentUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "인증되지 않은 사용자입니다."); - } - - String currentUserEmail = authentication.getName(); - Optional currentUserOpt = userService.findByEmail(currentUserEmail); - - if (currentUserOpt.isEmpty()) { - throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "사용자를 찾을 수 없습니다."); - } - - User currentUser = currentUserOpt.get(); - Long userId = currentUser.getUserId(); - - // Long을 Integer로 변환 (기존 코드와의 호환성을 위해) - return userId.intValue(); - } + ResponseEntity>> getFacilityItems( + @ApiParam(value = "카테고리 ID", required = true) @PathVariable Integer categoryId); } diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java new file mode 100644 index 00000000..7d612027 --- /dev/null +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -0,0 +1,331 @@ +package org.scoula.domain.home.controller; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import javax.validation.Valid; + +import org.scoula.domain.home.dto.HomeCreateDTO; +import org.scoula.domain.home.dto.HomeCreateRequestDto; +import org.scoula.domain.home.dto.HomeResponseDTO; +import org.scoula.domain.home.dto.HomeSearchDTO; +import org.scoula.domain.home.dto.HomeUpdateRequestDto; +import org.scoula.domain.home.service.HomeService; +import org.scoula.domain.home.vo.FacilityCategory; +import org.scoula.domain.home.vo.FacilityItem; +import org.scoula.domain.user.service.UserServiceInterface; +import org.scoula.domain.user.vo.User; +import org.scoula.global.common.dto.ApiResponse; +import org.scoula.global.common.exception.BusinessException; +import org.scoula.global.common.exception.CommonErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@RestController +@RequestMapping("/api/homes") +@RequiredArgsConstructor +public class HomeControllerImpl implements HomeController { + + private final HomeService homeService; + private final UserServiceInterface userService; + + @Override + @PostMapping + public ResponseEntity> createHome( + @Valid @ModelAttribute HomeCreateRequestDto requestDto, + @RequestParam(value = "images", required = false) List images, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + // 이미지 파일 리스트 처리 + List imageList = new ArrayList<>(); + if (images != null && !images.isEmpty()) { + for (MultipartFile image : images) { + if (image != null && !image.isEmpty()) { + imageList.add(image); + } + } + } + + log.info( + "매물 등록 요청: userId={}, residenceType={}, 이미지 개수={}", + userId, + requestDto.getResidenceType(), + imageList.size()); + + // HomeCreateRequestDto를 HomeCreateDTO로 변환 + HomeCreateDTO createDTO = + HomeCreateDTO.builder() + .addr1(requestDto.getAddr1()) + .addr2(requestDto.getAddr2()) + .residenceType(requestDto.getResidenceType()) + .leaseType(requestDto.getLeaseType()) + .depositPrice(requestDto.getDepositPrice()) + .monthlyRent(requestDto.getMonthlyRent()) + .maintenaceFee(requestDto.getMaintenanceFee()) + .roomCnt(requestDto.getRoomCnt()) + .supplyArea(requestDto.getSupplyArea()) + .exclusiveArea(requestDto.getExclusiveArea()) + .buildDate(requestDto.getBuildDate()) + .homeFloor(requestDto.getHomeFloor()) + .buildingTotalFloors(requestDto.getBuildingTotalFloors()) + .homeDirection(requestDto.getHomeDirection()) + .bathroomCnt(requestDto.getBathroomCnt()) + .isPet(requestDto.getIsPet()) + .isParking(requestDto.getIsParking()) + .facilityItemIds(requestDto.getFacilityItemIds()) + .maintenanceFees( + requestDto.getMaintenanceFees() != null + ? requestDto.getMaintenanceFees().stream() + .map( + fee -> + HomeCreateDTO.MaintenanceFeeDTO + .builder() + .maintenanceId( + fee + .getMaintenanceId()) + .fee(fee.getFee()) + .build()) + .collect(java.util.stream.Collectors.toList()) + : null) + .build(); + + Integer homeId = homeService.createHome(createDTO, imageList, userId); + + return ResponseEntity.ok(ApiResponse.success(homeId, "매물이 성공적으로 등록되었습니다.")); + } + + @Override + @GetMapping("/{homeId}") + public ResponseEntity> getHome(@PathVariable Integer homeId) { + + log.info("매물 상세 조회 요청: homeId={}", homeId); + + HomeResponseDTO home = homeService.getHome(homeId); + + return ResponseEntity.ok(ApiResponse.success(home, "매물 조회가 완료되었습니다.")); + } + + @Override + @GetMapping + public ResponseEntity>> getHomeList( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + + log.info("매물 목록 조회 요청: page={}, size={}", page, size); + + List homes = homeService.getHomeList(page, size); + int totalCount = homeService.getTotalHomeCount(); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("매물 목록 조회가 완료되었습니다. (총 %d개)", totalCount))); + } + + @Override + @GetMapping("/search") + public ResponseEntity>> searchHomes( + @ModelAttribute HomeSearchDTO searchDTO) { + + log.info( + "매물 검색 요청: residenceType={}, leaseType={}, addr1={}", + searchDTO.getResidenceType(), + searchDTO.getLeaseType(), + searchDTO.getAddr1()); + + List homes = homeService.searchHomes(searchDTO); + int totalCount = homeService.getHomeCountByCondition(searchDTO); + + return ResponseEntity.ok( + ApiResponse.success(homes, String.format("매물 검색이 완료되었습니다. (총 %d개)", totalCount))); + } + + @Override + @PutMapping("/{homeId}") + public ResponseEntity> updateHome( + @PathVariable Integer homeId, + @Valid @ModelAttribute HomeUpdateRequestDto updateDto, + @RequestParam(value = "newImages", required = false) List newImages, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info( + "매물 수정 요청: homeId={}, userId={}, 새 이미지 개수={}, 삭제할 이미지 개수={}", + homeId, + userId, + newImages != null ? newImages.size() : 0, + updateDto.getDeleteImageIds() != null ? updateDto.getDeleteImageIds().size() : 0); + + // 요청 DTO의 homeId 설정 (경로 변수의 homeId 사용) + updateDto.setHomeId(homeId); + + // HomeUpdateRequestDto를 HomeCreateDTO로 변환 + HomeCreateDTO updateDTO = + HomeCreateDTO.builder() + .addr1(updateDto.getAddr1()) + .addr2(updateDto.getAddr2()) + .residenceType(updateDto.getResidenceType()) + .leaseType(updateDto.getLeaseType()) + .depositPrice(updateDto.getDepositPrice()) + .monthlyRent(updateDto.getMonthlyRent()) + .maintenaceFee(updateDto.getMaintenanceFee()) + .roomCnt(updateDto.getRoomCnt()) + .supplyArea(updateDto.getSupplyArea()) + .exclusiveArea(updateDto.getExclusiveArea()) + .buildDate(updateDto.getBuildDate()) + .homeFloor(updateDto.getHomeFloor()) + .buildingTotalFloors(updateDto.getBuildingTotalFloors()) + .homeDirection(updateDto.getHomeDirection()) + .bathroomCnt(updateDto.getBathroomCnt()) + .isPet(updateDto.getIsPet()) + .isParking(updateDto.getIsParking()) + .facilityItemIds(updateDto.getFacilityItemIds()) + .maintenanceFees( + updateDto.getMaintenanceFees() != null + ? updateDto.getMaintenanceFees().stream() + .map( + fee -> + HomeCreateDTO.MaintenanceFeeDTO + .builder() + .maintenanceId( + fee + .getMaintenanceId()) + .fee(fee.getFee()) + .build()) + .collect(java.util.stream.Collectors.toList()) + : null) + .build(); + + homeService.updateHome(homeId, updateDTO, newImages, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 수정되었습니다.")); + } + + @Override + @DeleteMapping("/{homeId}") + public ResponseEntity> deleteHome( + @PathVariable Integer homeId, Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 삭제 요청: homeId={}, userId={}", homeId, userId); + + homeService.deleteHome(homeId, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물이 성공적으로 삭제되었습니다.")); + } + + @Override + @PatchMapping("/{homeId}/status") + public ResponseEntity> updateHomeStatus( + @PathVariable Integer homeId, + @RequestParam String status, + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 상태 변경 요청: homeId={}, status={}, userId={}", homeId, status, userId); + + homeService.updateHomeStatus(homeId, status, userId); + + return ResponseEntity.ok(ApiResponse.success(null, "매물 상태가 성공적으로 변경되었습니다.")); + } + + @Override + @GetMapping("/my") + public ResponseEntity>> getMyHomes( + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("내 매물 목록 조회 요청: userId={}", userId); + + List homes = homeService.getHomesByUser(userId); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("내 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); + } + + @Override + @PostMapping("/{homeId}/like") + public ResponseEntity> toggleHomeLike( + @PathVariable Integer homeId, Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("매물 찜 토글 요청: homeId={}, userId={}", homeId, userId); + + homeService.toggleHomeLike(userId, homeId); + + return ResponseEntity.ok(ApiResponse.success(null, "찜 상태가 변경되었습니다.")); + } + + @Override + @GetMapping("/likes") + public ResponseEntity>> getHomeLikes( + Authentication authentication) { + + Integer userId = getCurrentUserId(authentication); + + log.info("찜한 매물 목록 조회 요청: userId={}", userId); + + List homes = homeService.getHomeLikes(userId); + + return ResponseEntity.ok( + ApiResponse.success( + homes, String.format("찜한 매물 목록 조회가 완료되었습니다. (총 %d개)", homes.size()))); + } + + @Override + @GetMapping("/facilities/categories") + public ResponseEntity>> getFacilityCategories() { + + log.info("편의시설 카테고리 조회 요청"); + + List categories = homeService.getFacilityCategories(); + + return ResponseEntity.ok(ApiResponse.success(categories, "편의시설 카테고리 조회가 완료되었습니다.")); + } + + @Override + @GetMapping("/facilities/categories/{categoryId}/items") + public ResponseEntity>> getFacilityItems( + @PathVariable Integer categoryId) { + + log.info("편의시설 아이템 조회 요청: categoryId={}", categoryId); + + List items = homeService.getFacilityItemsByCategory(categoryId); + + return ResponseEntity.ok(ApiResponse.success(items, "편의시설 아이템 조회가 완료되었습니다.")); + } + + /** Authentication에서 사용자 ID를 추출하는 메서드 실제 구현시에는 JWT에서 사용자 정보를 추출 */ + private Integer getCurrentUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "인증되지 않은 사용자입니다."); + } + + String currentUserEmail = authentication.getName(); + Optional currentUserOpt = userService.findByEmail(currentUserEmail); + + if (currentUserOpt.isEmpty()) { + throw new BusinessException(CommonErrorCode.AUTHENTICATION_FAILED, "사용자를 찾을 수 없습니다."); + } + + User currentUser = currentUserOpt.get(); + Long userId = currentUser.getUserId(); + + // Long을 Integer로 변환 (기존 코드와의 호환성을 위해) + return userId.intValue(); + } +} From 139087015d4c6d6eccf9a0877920d767747f20ef Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:50:25 +0900 Subject: [PATCH 14/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20Service=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불필요한 디버깅 로그 제거 - 이미지 업로드 로직 단순화 - S3 경로 처리 개선 (home/{homeId}/{fileName} 형식) - 에러 처리 및 로깅 메시지 최적화 --- .../domain/home/service/HomeServiceImpl.java | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index 4895bfe8..8b33a850 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -18,9 +18,9 @@ import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import lombok.extern.log4j.Log4j2; -@Slf4j +@Log4j2 @Service @RequiredArgsConstructor @Transactional @@ -115,17 +115,7 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I log.info("관리비 정보 등록 완료: 개수={}", createDTO.getMaintenanceFees().size()); } - // 1. URL 방식 이미지 처리 (기존 방식) - if (createDTO.getImageUrls() != null && !createDTO.getImageUrls().isEmpty()) { - for (String imageUrl : createDTO.getImageUrls()) { - HomeImageVO homeImage = - HomeImageVO.builder().homeId(homeId).ImageUrl(imageUrl).build(); - homeMapper.insertHomeImage(homeImage); - } - log.info("URL 이미지 등록 완료: 개수={}", createDTO.getImageUrls().size()); - } - - // 2. 파일 업로드 방식 이미지 처리 (새로운 방식) + // 파일 업로드 방식 이미지 처리 if (images != null && !images.isEmpty()) { int successCount = 0; for (MultipartFile image : images) { @@ -133,15 +123,22 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I try { String fileName = generateHomeImageFileName(homeId, image.getOriginalFilename()); - String s3Key = "home-images/" + homeId + "/" + fileName; - String imageUrl = s3Service.uploadFile(image, s3Key); + // S3에 업로드 (uploads/home/{homeId}/{fileName} 형식으로 저장됨) + String uploadedKey = + s3Service.uploadFile(image, "home/" + homeId + "/" + fileName); + String imageUrl = s3Service.getFileUrl(uploadedKey); HomeImageVO homeImage = - HomeImageVO.builder().homeId(homeId).ImageUrl(imageUrl).build(); + HomeImageVO.builder().homeId(homeId).imageUrl(imageUrl).build(); + homeMapper.insertHomeImage(homeImage); successCount++; - log.info("파일 이미지 S3 업로드 완료: homeId={}, imageUrl={}", homeId, imageUrl); + log.info( + "이미지 업로드 완료: homeId={}, fileName={}, imageUrl={}", + homeId, + fileName, + imageUrl); } catch (Exception e) { log.error( "이미지 업로드 실패: homeId={}, fileName={}", @@ -309,7 +306,8 @@ public List getHomeList(int page, int size) { } @Override - public void updateHome(Integer homeId, HomeCreateDTO updateDTO, Integer userId) { + public void updateHome( + Integer homeId, HomeCreateDTO updateDTO, List images, Integer userId) { HomeVO existingHome = homeMapper.selectHomeById(homeId); if (existingHome == null) { throw new BusinessException(CommonErrorCode.ENTITY_NOT_FOUND, "존재하지 않는 매물입니다."); @@ -318,6 +316,45 @@ public void updateHome(Integer homeId, HomeCreateDTO updateDTO, Integer userId) throw new BusinessException(CommonErrorCode.UNAUTHORIZED_ACCESS, "매물을 수정할 권한이 없습니다."); } + // TODO: 매물 기본 정보 업데이트 로직 구현 + // homeMapper.updateHome(homeId, updateDTO); + + // 새로운 이미지 업로드 처리 + if (images != null && !images.isEmpty()) { + int successCount = 0; + for (MultipartFile image : images) { + if (!image.isEmpty()) { + try { + String fileName = + generateHomeImageFileName(homeId, image.getOriginalFilename()); + // S3에 업로드 (uploads/home/{homeId}/{fileName} 형식으로 저장됨) + String uploadedKey = + s3Service.uploadFile(image, "home/" + homeId + "/" + fileName); + String imageUrl = s3Service.getFileUrl(uploadedKey); + + HomeImageVO homeImage = + HomeImageVO.builder().homeId(homeId).imageUrl(imageUrl).build(); + + homeMapper.insertHomeImage(homeImage); + successCount++; + + log.info( + "이미지 추가 업로드 완료: homeId={}, fileName={}, imageUrl={}", + homeId, + fileName, + imageUrl); + } catch (Exception e) { + log.error( + "이미지 업로드 실패: homeId={}, fileName={}", + homeId, + image.getOriginalFilename(), + e); + } + } + } + log.info("매물 이미지 추가 완료: homeId={}, 성공한 이미지 개수={}", homeId, successCount); + } + log.info("매물 수정 완료: homeId={}, userId={}", homeId, userId); } From 9e0c538ff2ed706317d50f3a351765b707265ada Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:54:06 +0900 Subject: [PATCH 15/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20Service=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateHome 메서드에 이미지 파라미터 추가 - 매물 수정 시 이미지 업로드 기능 지원 --- .../java/org/scoula/domain/home/service/HomeService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/service/HomeService.java b/src/main/java/org/scoula/domain/home/service/HomeService.java index 7230e9a3..858a478d 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeService.java +++ b/src/main/java/org/scoula/domain/home/service/HomeService.java @@ -24,8 +24,9 @@ public interface HomeService { // 매물 검색 List searchHomes(HomeSearchDTO searchDTO); - // 매물 수정 - void updateHome(Integer homeId, HomeCreateDTO updateDTO, Integer userId); + // 매물 수정 (이미지 선택사항) + void updateHome( + Integer homeId, HomeCreateDTO updateDTO, List images, Integer userId); // 매물 삭제 void deleteHome(Integer homeId, Integer userId); From f00258b79d265a7cdc91579ce2e67df8c1f52193 Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:56:31 +0900 Subject: [PATCH 16/34] =?UTF-8?q?=F0=9F=90=9B=20fix:=20ContractChatService?= =?UTF-8?q?Impl=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메서드 호출 공백 일관성 개선 - 코드 포맷팅 통일 --- .../chat/service/ContractChatServiceImpl.java | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java index f06862fd..c9d95ab9 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -2002,16 +2002,14 @@ public ModificationRequestData requestFinalContractModification( String jsonData = objectMapper.writeValueAsString(requestData); stringRedisTemplate.opsForValue().set(redisKey, jsonData, Duration.ofHours(24)); - String notificationMessage = String.format( - "임대인이 특약 %d번 수정을 요청했습니다.\n\n" + - "📝 수정 제목: %s\n" + - "✏️ 수정 내용: %s\n\n", - requestDto.getClauseOrder(), - requestDto.getNewTitle(), - requestDto.getNewContent() - ); - - AiMessageBtn(contractChatId,notificationMessage); + String notificationMessage = + String.format( + "임대인이 특약 %d번 수정을 요청했습니다.\n\n" + "📝 수정 제목: %s\n" + "✏️ 수정 내용: %s\n\n", + requestDto.getClauseOrder(), + requestDto.getNewTitle(), + requestDto.getNewContent()); + + AiMessageBtn(contractChatId, notificationMessage); log.info("수정 요청 Redis 저장 완료 - key: {}", redisKey); return requestData; @@ -2104,7 +2102,7 @@ public FinalSpecialContractDocument respondToModificationRequest( stringRedisTemplate.delete(redisKey); - AiMessage(contractChatId,resultMessage); + AiMessage(contractChatId, resultMessage); return finalContract; } catch (Exception e) { @@ -2156,7 +2154,7 @@ public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); } - AiMessage(contractChatId,"특약을 수락하였습니다"); + AiMessage(contractChatId, "특약을 수락하였습니다"); String key = "final-contract:confirmation:" + contractChatId; String existingValue = stringRedisTemplate.opsForValue().get(key); @@ -2166,7 +2164,6 @@ public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) } String value = ownerId.toString(); stringRedisTemplate.opsForValue().set(key, value); - } @Override @@ -2212,7 +2209,7 @@ public Map acceptFinalContractConfirmation(Long contractChatId, String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; - AiMessage(contractChatId,confirmationMessage); + AiMessage(contractChatId, confirmationMessage); return Map.of( "message", @@ -2230,7 +2227,7 @@ public void rejectFinalContractConfirmation(Long contractChatId, Long buyerId) { String redisKey = "final-contract:confirmation:" + contractChatId; stringRedisTemplate.delete(redisKey); - AiMessage(contractChatId,"임차인이 수정을 거절하였습니다."); + AiMessage(contractChatId, "임차인이 수정을 거절하였습니다."); } @Override @@ -2282,7 +2279,7 @@ public void requestFinalContractDeletion( String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); - AiMessage(contractChatId,notificationMessage); + AiMessage(contractChatId, notificationMessage); log.info("삭제 요청 Redis 저장 완료 - key: {}, value: {}", redisKey, ownerId); } @@ -2340,7 +2337,7 @@ public Map acceptFinalContractDeletion( String confirmationMessage = String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); - AiMessage(contractChatId,confirmationMessage); + AiMessage(contractChatId, confirmationMessage); log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); @@ -2388,7 +2385,7 @@ public void rejectFinalContractDeletion( String rejectionMessage = String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); - AiMessage(contractChatId,rejectionMessage); + AiMessage(contractChatId, rejectionMessage); log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); } From 50b13b1c72a562f630a3414d332a74fdb152507b Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:56:54 +0900 Subject: [PATCH 17/34] =?UTF-8?q?=F0=9F=90=9B=20fix:=20HomeResponseDto=20b?= =?UTF-8?q?uildDate=20=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalDateTime.toLocalDate() 호출 제거 - buildDate가 이미 LocalDate 타입으로 처리되도록 수정 --- .../org/scoula/domain/home/dto/response/HomeResponseDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java b/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java index 18b70842..94d76292 100644 --- a/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java +++ b/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java @@ -103,7 +103,7 @@ public static HomeResponseDto from( .homeDirection(vo.getHomeDirection() != null ? vo.getHomeDirection().name() : null) .isPet(vo.getIsPet()) .isParkingAvailable(vo.getIsParkingAvailable()) - .buildDate(vo.getBuildDate() != null ? vo.getBuildDate().toLocalDate() : null) + .buildDate(vo.getBuildDate() != null ? vo.getBuildDate() : null) .moveInDate(vo.getMoveInDate()) .imageUrl(vo.getImageUrl()) .imageId(vo.getImageId()) From 18ea69bd13f48cc7bc41d4171c66c736da11b90e Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:57:13 +0900 Subject: [PATCH 18/34] =?UTF-8?q?=E2=99=BB=20refactor:=20HomeRegisterVO=20?= =?UTF-8?q?DTO=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeCreateRequestDto/HomeUpdateRequestDto import 경로 수정 - buildDate 타입을 LocalDateTime에서 LocalDate로 변경 - 필드명 매핑 수정 (bathroomCount -> bathroomCnt, isParkingAvailable -> isParking) - MaintenanceFeeDTO를 MaintenanceFeeItem으로 변환하는 로직 추가 - Integer를 Long으로 변환하는 로직 추가 --- .../scoula/domain/home/vo/HomeRegisterVO.java | 108 +++++++++++++----- 1 file changed, 80 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java b/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java index 1e33625e..f7cac976 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java @@ -4,8 +4,8 @@ import java.time.LocalDateTime; import java.util.List; -import org.scoula.domain.home.dto.request.HomeCreateRequestDto; -import org.scoula.domain.home.dto.request.HomeUpdateRequestDto; +import org.scoula.domain.home.dto.HomeCreateRequestDto; +import org.scoula.domain.home.dto.HomeUpdateRequestDto; import org.scoula.domain.home.enums.HomeDirection; import org.scoula.domain.home.enums.HomeStatus; import org.scoula.domain.home.enums.LeaseType; @@ -47,7 +47,7 @@ public class HomeRegisterVO { // 상세 정보 private Long homeDetailId; - private LocalDateTime buildDate; + private LocalDate buildDate; private Integer floor; private Integer buildingTotalFloors; private HomeDirection homeDirection; @@ -80,12 +80,37 @@ public static class MaintenanceFeeItem { // 생성용 from (HomeCreateRequestDto) public static HomeRegisterVO from(Long userId, HomeCreateRequestDto dto) { - LocalDateTime parsedBuildDate = - dto.getBuildDate() != null ? dto.getBuildDate().atStartOfDay() : null; + + // MaintenanceFeeDTO를 MaintenanceFeeItem으로 변환 + List maintenanceItems = null; + if (dto.getMaintenanceFees() != null) { + maintenanceItems = + dto.getMaintenanceFees().stream() + .map( + fee -> + MaintenanceFeeItem.builder() + .maintenanceId( + fee.getMaintenanceId() != null + ? fee.getMaintenanceId() + .longValue() + : null) + .fee(fee.getFee()) + .build()) + .collect(java.util.stream.Collectors.toList()); + } + + // facilityItemIds를 Long 리스트로 변환 + List facilityIds = null; + if (dto.getFacilityItemIds() != null) { + facilityIds = + dto.getFacilityItemIds().stream() + .map(Integer::longValue) + .collect(java.util.stream.Collectors.toList()); + } return HomeRegisterVO.builder() .userId(userId) - .userName(dto.getUserName()) + // userName은 HomeCreateRequestDto에 없음 .addr1(dto.getAddr1()) .addr2(dto.getAddr2()) .residenceType(dto.getResidenceType()) @@ -95,18 +120,18 @@ public static HomeRegisterVO from(Long userId, HomeCreateRequestDto dto) { .maintenanceFee(dto.getMaintenanceFee()) .supplyArea(dto.getSupplyArea() != null ? dto.getSupplyArea() : 0f) .exclusiveArea(dto.getExclusiveArea()) - .homeFloor(dto.getHomeFloor()) + .homeFloor(dto.getHomeFloor() != null ? dto.getHomeFloor().toString() : null) .roomCnt(dto.getRoomCnt()) - .bathroomCount(dto.getBathroomCount()) - .facilityItemIds(dto.getFacilityItemIds()) - .buildDate(parsedBuildDate) - .options(dto.getOptions()) - .isParkingAvailable(dto.getIsParkingAvailable()) + .bathroomCount(dto.getBathroomCnt()) // 주의: 필드명 다름 + .facilityItemIds(facilityIds) + .buildDate(dto.getBuildDate()) + // options는 HomeCreateRequestDto에 없음 + .isParkingAvailable(dto.getIsParking()) // 주의: 필드명 다름 .buildingTotalFloors(dto.getBuildingTotalFloors()) .isPet(dto.getIsPet()) - .moveInDate(dto.getMoveInDate()) - .maintenanceItems(dto.getMaintenanceFeeItems()) - .imageUrls(dto.getImageUrls()) + // moveInDate는 HomeCreateRequestDto에 없음 + .maintenanceItems(maintenanceItems) + // imageUrls는 images 필드가 MultipartFile이므로 여기서는 처리 안함 .build(); } @@ -115,10 +140,37 @@ public static HomeRegisterVO from(Long userId, HomeUpdateRequestDto dto) { LocalDateTime parsedBuildDate = dto.getBuildDate() != null ? dto.getBuildDate().atStartOfDay() : null; + // MaintenanceFeeDTO를 MaintenanceFeeItem으로 변환 + List maintenanceItems = null; + if (dto.getMaintenanceFees() != null) { + maintenanceItems = + dto.getMaintenanceFees().stream() + .map( + fee -> + MaintenanceFeeItem.builder() + .maintenanceId( + fee.getMaintenanceId() != null + ? fee.getMaintenanceId() + .longValue() + : null) + .fee(fee.getFee()) + .build()) + .collect(java.util.stream.Collectors.toList()); + } + + // facilityItemIds를 Long 리스트로 변환 + List facilityIds = null; + if (dto.getFacilityItemIds() != null) { + facilityIds = + dto.getFacilityItemIds().stream() + .map(Integer::longValue) + .collect(java.util.stream.Collectors.toList()); + } + return HomeRegisterVO.builder() - .homeId(dto.getHomeId()) + .homeId(dto.getHomeId() != null ? dto.getHomeId().longValue() : null) .userId(userId) - .userName(dto.getUserName()) + // userName은 HomeUpdateRequestDto에 없음 .addr1(dto.getAddr1()) .addr2(dto.getAddr2()) .residenceType(dto.getResidenceType()) @@ -126,21 +178,21 @@ public static HomeRegisterVO from(Long userId, HomeUpdateRequestDto dto) { .depositPrice(dto.getDepositPrice()) .monthlyRent(dto.getMonthlyRent()) .maintenanceFee(dto.getMaintenanceFee()) - .supplyArea(dto.getSupplyArea()) + .supplyArea(dto.getSupplyArea() != null ? dto.getSupplyArea() : 0f) .exclusiveArea(dto.getExclusiveArea()) - .homeFloor(dto.getHomeFloor()) + .homeFloor(dto.getHomeFloor() != null ? dto.getHomeFloor().toString() : null) .roomCnt(dto.getRoomCnt()) - .bathroomCount(dto.getBathroomCount()) - .homeDirection(parseHomeDirection(dto.getHomeDirection())) - .imageUrls(dto.getImageUrls()) - .facilityItemIds(dto.getFacilityItemIds()) - .options(dto.getOptions()) - .isParkingAvailable(dto.getIsParkingAvailable()) + .bathroomCount(dto.getBathroomCnt()) // 주의: 필드명 다름 + .homeDirection(dto.getHomeDirection()) + .imageUrls(dto.getExistingImageUrls()) // 기존 이미지 URL 리스트 + .facilityItemIds(facilityIds) + // options는 HomeUpdateRequestDto에 없음 + .isParkingAvailable(dto.getIsParking()) // 주의: 필드명 다름 .buildingTotalFloors(dto.getBuildingTotalFloors()) .isPet(dto.getIsPet()) - .moveInDate(dto.getMoveInDate()) - .buildDate(parsedBuildDate) - .maintenanceItems(dto.getMaintenanceFeeItems()) + // moveInDate는 HomeUpdateRequestDto에 없음 + .buildDate(dto.getBuildDate()) + .maintenanceItems(maintenanceItems) .build(); } From f6cf1b58738c74c14a78ee2940d3e1711dfddb3d Mon Sep 17 00:00:00 2001 From: MeongW Date: Tue, 12 Aug 2025 12:57:43 +0900 Subject: [PATCH 19/34] =?UTF-8?q?=E2=9C=A8=20feat:=20ServletConfig?= =?UTF-8?q?=EC=97=90=20LocalDate=20=EC=BB=A8=EB=B2=84=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - multipart/form-data에서 LocalDate 타입 변환 지원 - ISO_LOCAL_DATE 포맷 (yyyy-MM-dd) 처리 - 빈 문자열 처리 지원 --- .../scoula/global/config/ServletConfig.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/org/scoula/global/config/ServletConfig.java b/src/main/java/org/scoula/global/config/ServletConfig.java index a1879a10..326966da 100644 --- a/src/main/java/org/scoula/global/config/ServletConfig.java +++ b/src/main/java/org/scoula/global/config/ServletConfig.java @@ -1,10 +1,14 @@ package org.scoula.global.config; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.format.FormatterRegistry; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.multipart.MultipartResolver; @@ -71,4 +75,19 @@ public void configureMessageConverters(List> converters) converter.setObjectMapper(objectMapper); converters.add(converter); } + + @Override + public void addFormatters(FormatterRegistry registry) { + // LocalDate converter for form data + registry.addConverter( + new Converter() { + @Override + public LocalDate convert(String source) { + if (source == null || source.trim().isEmpty()) { + return null; + } + return LocalDate.parse(source, DateTimeFormatter.ISO_LOCAL_DATE); + } + }); + } } From f9d44eb26cc79b04bebbfe0b4140d7eae69f6a8c Mon Sep 17 00:00:00 2001 From: seonju21 Date: Tue, 12 Aug 2025 13:25:01 +0900 Subject: [PATCH 20/34] =?UTF-8?q?=E2=99=BB=20refactor:=20home=5Fdetail?= =?UTF-8?q?=EC=97=90=20area,=20land=5Fcategory=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java | 4 ++++ .../org/scoula/domain/home/dto/HomeCreateRequestDto.java | 6 ++++++ .../domain/home/dto/request/HomeCreateRequestDto.java | 2 ++ .../org/scoula/domain/home/service/HomeServiceImpl.java | 2 ++ src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java | 3 +++ src/main/java/org/scoula/domain/home/vo/HomeVO.java | 3 +++ .../resources/org/scoula/domain/home/mapper/HomeMapper.xml | 5 +++-- 7 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java index 4998f60f..04eb02f8 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java @@ -38,6 +38,10 @@ public class HomeCreateDTO { private Float supplyArea; + private Float area; + + private String landCategory; + private Float exclusiveArea; private LocalDate buildDate; diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java index de9a9c26..1c6d0053 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java @@ -61,6 +61,12 @@ public class HomeCreateRequestDto { @ApiModelProperty(value = "전용 면적 (㎡)", example = "59.8") private Float exclusiveArea; + @ApiModelProperty(value = "면적 (㎡)", example = "62.4") + private Float area; + + @ApiModelProperty(value = "토지 지목", example = "대,전,답") + private String landCategory; + @ApiModelProperty(value = "준공일", example = "2020-03-15") private LocalDate buildDate; diff --git a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java index b55cedeb..4e021887 100644 --- a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java @@ -34,6 +34,8 @@ public class HomeCreateRequestDto { private Float supplyArea; // 공급면적 private Float exclusiveArea; // 전용면적 + private Float area; + private String landCategory; private String homeFloor; // 층 정보 (예: "5층 / 15층" 등) diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index 8b33a850..02b3962a 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -77,6 +77,8 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I .bathroomCnt(createDTO.getBathroomCnt()) .isPet(createDTO.getIsPet()) .isParking(createDTO.getIsParking()) + .area(createDTO.getArea()) + .landCategory(createDTO.getLandCategory()) .build(); int detailResult = homeMapper.insertHomeDetail(homeDetail); diff --git a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java index e67b0568..28b96715 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java @@ -22,4 +22,7 @@ public class HomeDetailVO { private Integer bathroomCnt; private Boolean isPet; private Boolean isParking; + private Float area; + private String landCategory; + } diff --git a/src/main/java/org/scoula/domain/home/vo/HomeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeVO.java index 3caeb181..415f6ea8 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeVO.java @@ -34,4 +34,7 @@ public class HomeVO { private LocalDate updatedAt; private String userName; private Float exclusiveArea; + private Float area; + private String landCategory; + } diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index b1112ba6..f3b600ed 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -22,10 +22,11 @@ INSERT INTO home_detail ( home_id, build_date, home_floor, building_total_floors, - home_direction, bathroom_count, is_pet, is_parking_available + home_direction, bathroom_count, is_pet, is_parking_available,area,landCategory + ) VALUES ( #{homeId}, #{buildDate}, #{homeFloor}, #{buildingTotalFloors}, - #{homeDirection}, #{bathroomCnt}, #{isPet}, #{isParking} + #{homeDirection}, #{bathroomCnt}, #{isPet}, #{isParking}, #{area}, #{landCategory} ) From 2c02e0714b3f0887fa24a1e91f25aadfcb2ef67a Mon Sep 17 00:00:00 2001 From: seonju21 Date: Tue, 12 Aug 2025 15:11:15 +0900 Subject: [PATCH 21/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ContractChatServiceImpl.java | 4706 ++++++++--------- .../home/controller/HomeController.java | 6 +- .../home/controller/HomeControllerImpl.java | 18 +- .../domain/home/dto/HomeCreateRequestDto.java | 4 + .../scoula/domain/home/vo/HomeDetailVO.java | 1 - .../org/scoula/domain/home/vo/HomeVO.java | 1 - .../scoula/domain/home/mapper/HomeMapper.xml | 2 +- 7 files changed, 2364 insertions(+), 2374 deletions(-) diff --git a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java index 24ea8e33..c9d95ab9 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -37,2356 +37,2356 @@ @Slf4j public class ContractChatServiceImpl implements ContractChatServiceInterface { - private final ContractChatMapper contractChatMapper; - private final ChatRoomMapper chatRoomMapper; - private final ContractChatMessageRepository contractChatMessageRepository; - private final SimpMessagingTemplate messagingTemplate; - @Lazy private final ChatServiceInterface chatService; - private final AiClauseImproveService aiClauseImproveService; - private final PreContractDataService preContractDataService; - - private final Map> contractChatOnlineUsers = new ConcurrentHashMap<>(); - private final RedisTemplate stringRedisTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); - @Autowired private SpecialContractMongoRepository specialContractMongoRepository; - - /** {@inheritDoc} */ - @Override - @Transactional - public Long createContractChat(Long chatRoomId, Long userId) { - ChatRoom chatRoom = chatRoomMapper.findById(chatRoomId); - if (chatRoom == null) { - throw new EntityNotFoundException("채팅방을 찾을 수 없습니다: " + chatRoomId); - } - - if (!chatService.isUserInChatRoom(chatRoomId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChat existingContract = - contractChatMapper.findByUserAndHome( - chatRoom.getOwnerId(), chatRoom.getBuyerId(), chatRoom.getHomeId()); - - if (existingContract != null) { - return existingContract.getContractChatId(); - } - - ContractChat contractChat = new ContractChat(); - contractChat.setHomeId(chatRoom.getHomeId()); - contractChat.setOwnerId(chatRoom.getOwnerId()); - contractChat.setBuyerId(chatRoom.getBuyerId()); - contractChat.setContractStartAt(LocalDateTime.now()); - contractChat.setLastMessage("계약이 시작되었습니다."); - - contractChatMapper.createContractChat(contractChat); - - return contractChat.getContractChatId(); - } - - /** {@inheritDoc} */ - @Override - @Transactional - public void handleContractChatMessage(ContractChatMessageRequestDto dto) { - if (dto.getContractChatId() == null || dto.getContent() == null) { - throw new IllegalArgumentException("필수 파라미터가 누락되었습니다."); - } - - if (!isUserInContractChat(dto.getContractChatId(), dto.getSenderId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChatDocument messageDocument = - ContractChatDocument.builder() - .contractChatId(dto.getContractChatId().toString()) - .senderId(dto.getSenderId()) - .receiverId(dto.getReceiverId()) - .content(dto.getContent()) - .sendTime(LocalDateTime.now().toString()) - .build(); - - try { - ContractChatDocument savedMessage = - contractChatMessageRepository.saveMessage(messageDocument); - - contractChatMapper.updateLastMessage(dto.getContractChatId(), dto.getContent()); - - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + dto.getContractChatId(), savedMessage); - - } catch (Exception e) { - log.error("메시지 처리 중 오류 발생", e); - throw e; - } - } - - public void AiMessage(Long contractChatId, String content) { - final Long ai = 9999L; - - ContractChatDocument aiMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ai) - .receiverId(null) - .content(content) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(aiMessage); - contractChatMapper.updateLastMessage(contractChatId, content); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); - } - - public void AiMessageNext(Long contractChatId, String content) { - final Long ai = 9997L; - - ContractChatDocument aiMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ai) - .receiverId(null) - .content(content) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(aiMessage); - contractChatMapper.updateLastMessage(contractChatId, content); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); - } - - public void AiMessageBtn(Long contractChatId, String content) { - final Long ai = 9998L; - - ContractChatDocument aiMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ai) - .receiverId(null) - .content(content) - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(aiMessage); - contractChatMapper.updateLastMessage(contractChatId, content); - messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); - } - - /** {@inheritDoc} */ - @Override - public List getContractMessages(Long contractChatId) { - return contractChatMessageRepository.getMessages(contractChatId); - } - - /** 스타트 버튼 클릭 - 현재 시간을 시작점으로 설정 */ - @Override - @Transactional - public String setStartPoint(Long contractChatId, Long userId) { - if (!isUserInContractChat(contractChatId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - contractChatMapper.clearTimePoints(contractChatId); - String startTime = LocalDateTime.now().toString(); - - contractChatMapper.updateStartTime(contractChatId, startTime); - - return startTime; - } - - /** {@inheritDoc} */ - @Override - public ContractChat getContractChatInfo(Long contractChatId, Long userId) { - if (contractChatId == null || userId == null) { - throw new IllegalArgumentException("contractChatId와 userId는 null일 수 없습니다."); - } - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!userId.equals(contractChat.getOwnerId()) - && !userId.equals(contractChat.getBuyerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - return contractChat; - } - - /** {@inheritDoc} */ - @Override - @Transactional - public boolean setEndPointAndExport(Long contractChatId, Long userId, Long order) { - if (!isUserInContractChat(contractChatId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - Long ownerId = contractChat.getOwnerId(); - Long buyerId = contractChat.getBuyerId(); - - if (!userId.equals(buyerId)) { - throw new BusinessException( - ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "구매자만 특약 내보내기를 수락할 수 있습니다."); - } - - String redisKey = "contract:request-end:" + contractChatId; - String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); - - if (storedOwnerId == null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "특약 종료 요청이 존재하지 않습니다."); - } - - if (!storedOwnerId.equals(ownerId.toString())) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "특약 종료 요청 정보가 유효하지 않습니다."); - } - - String startTime = contractChat.getStartPoint(); - if (startTime == null || startTime.trim().isEmpty()) { - throw new BusinessException(ChatErrorCode.START_POINT_NOT_SET); - } - - String endTime = LocalDateTime.now().toString(); - contractChatMapper.updateEndTime(contractChatId, endTime); - - List exportMessages = - contractChatMessageRepository.getMessagesBetweenTime( - contractChatId, startTime, endTime); - - StringBuilder sb = new StringBuilder(); - if (exportMessages != null && !exportMessages.isEmpty()) { - for (ContractChatDocument msg : exportMessages) { - Long senderId = msg.getSenderId(); - String content = msg.getContent(); - - if (!content.equals("임대인이 특약 대화 종료 및 내보내기를 요청했습니다.") - && !content.equals("임차인이 특약 대화를 더 요청했습니다.")) { - - String senderRole = - senderId.equals(buyerId) - ? "구매자" - : senderId.equals(ownerId) ? "판매자" : "조회실패"; - String toai = String.format("%s: %s", senderRole, content); - sb.append(toai).append("\n"); - } - } - sb.append("특약 대화가 종료되었습니다."); - } else { - sb.append("조회된 특약 메시지가 없습니다."); - } - - stringRedisTemplate.delete(redisKey); - - String result = sb.toString(); - - SpecialContractFixDocument improveClauseRequest = - updateRecentData(contractChatId, order, result); - ClauseImproveResponseDto improveClauseResponse = getAiClauseImprove(improveClauseRequest); - - updateSpecialClause(contractChatId, improveClauseResponse); - - checkAndIncrementRoundIfComplete(contractChatId); - return true; - } - - private boolean isRejectedClause(Long contractChatId, Long order) { - try { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - Long currentRound = contractChat.getCurrentRound(); - - SpecialContractDocument currentDocument = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, currentRound) - .orElse(null); - - if (currentDocument == null) { - log.warn("현재 라운드 문서를 찾을 수 없음: round {}", currentRound); - return false; - } - - Optional clauseOpt = - currentDocument.getClauses().stream() - .filter(clause -> clause.getOrder().equals(order.intValue())) - .findFirst(); - - if (clauseOpt.isEmpty()) { - log.warn("특약 {}번을 현재 라운드에서 찾을 수 없음", order); - return false; - } - - SpecialContractDocument.Clause clause = clauseOpt.get(); - - boolean isEmpty = - (clause.getTitle() == null || clause.getTitle().trim().isEmpty()) - && (clause.getContent() == null - || clause.getContent().trim().isEmpty()); - - log.info( - "특약 {}번 상태 체크 - title: '{}', content: '{}', 거부된 특약: {}", - order, - clause.getTitle(), - clause.getContent(), - isEmpty); - - return isEmpty; - - } catch (Exception e) { - log.error("특약 {}번 거부 상태 체크 실패: {}", order, e.getMessage()); - return false; - } - } - - private void updateSpecialClause(Long contractChatId, ClauseImproveResponseDto response) { - - Long round = response.getData().getRound(); - Integer order = response.getData().getOrder(); - String content = response.getData().getContent(); - String title = response.getData().getTitle(); - - SpecialContractDocument.Assessment assessment = - SpecialContractDocument.Assessment.builder() - .owner( - SpecialContractDocument.Evaluation.builder() - .level( - response.getData() - .getAssessment() - .getOwner() - .getLevel()) - .reason( - response.getData() - .getAssessment() - .getOwner() - .getReason()) - .build()) - .tenant( - SpecialContractDocument.Evaluation.builder() - .level( - response.getData() - .getAssessment() - .getTenant() - .getLevel()) - .reason( - response.getData() - .getAssessment() - .getTenant() - .getReason()) - .build()) - .build(); - - SpecialContractDocument.Clause clause = - SpecialContractDocument.Clause.builder() - .order(order) - .title(title) - .content(content) - .assessment(assessment) - .build(); - - String id = - specialContractMongoRepository.updateSpecialContractForNewOrderAndRound( - contractChatId, round, order, clause); - } - - private ClauseImproveResponseDto getAiClauseImprove(SpecialContractFixDocument scfd) { - - Long contractChatId = scfd.getContractChatId(); - // 1. Owner 데이터 조회 - ClauseImproveRequestDto.OwnerData ownerData = - preContractDataService.fetchOwnerData(contractChatId); - - // 2. Tenant 데이터 조회 - ClauseImproveRequestDto.TenantData tenantData = - preContractDataService.fetchTenantData(contractChatId); - - // 3. OCR 데이터 조회 - ClauseImproveRequestDto.OcrData ocrData = - preContractDataService.fetchOcrData(contractChatId); - - // 4. 이전 특약 데이터 설정 (테스트용) - List prevClauses = scfd.getPrevData(); - - // 5. 최근 특약 데이터 설정 (테스트용) - ContentDataDto recentClause = scfd.getRecentData(); - - // 6. AI 특약 개선 요청 - ClauseImproveRequestDto aiRequest = - ClauseImproveRequestDto.builder() - .contractChatId(contractChatId) - .ocrData(ocrData) - .round(scfd.getRound()) - .order(scfd.getOrder()) - .ownerData(ownerData) - .tenantData(tenantData) - .prevData(scfd.getPrevData()) - .recentData(scfd.getRecentData()) - .build(); - - return aiClauseImproveService.improveClause(aiRequest); - } - - /** {@inheritDoc} */ - @Override - public boolean isUserInContractChat(Long contractChatId, Long userId) { - if (contractChatId == null || userId == null) { - return false; - } - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - return false; - } - - return userId.equals(contractChat.getOwnerId()) || userId.equals(contractChat.getBuyerId()); - } - - /** {@inheritDoc} */ - @Override - @Transactional - public void enterContractChatRoom(Long contractChatId, Long userId) { - if (!isUserInContractChat(contractChatId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - setContractChatUserOnline(userId, contractChatId); - } - - /** {@inheritDoc} */ - @Override - @Transactional - public void leaveContractChatRoom(Long contractChatId, Long userId) { - setContractChatUserOffline(userId, contractChatId); - } - - /** {@inheritDoc} */ - @Override - public Map getContractChatOnlineStatus(Long contractChatId, Long userId) { - if (!isUserInContractChat(contractChatId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - boolean ownerInContractRoom = - isUserInContractChatRoom(contractChat.getOwnerId(), contractChatId); - boolean buyerInContractRoom = - isUserInContractChatRoom(contractChat.getBuyerId(), contractChatId); - - boolean bothInRoom = ownerInContractRoom && buyerInContractRoom; - - return Map.of( - "ownerInContractRoom", ownerInContractRoom, - "buyerInContractRoom", buyerInContractRoom, - "bothInRoom", bothInRoom, - "canChat", bothInRoom, - "ownerId", contractChat.getOwnerId(), - "buyerId", contractChat.getBuyerId()); - } - - /** {@inheritDoc} */ - @Override - public boolean canSendContractMessage(Long contractChatId) { - try { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - return false; - } - - boolean ownerInContractRoom = - isUserInContractChatRoom(contractChat.getOwnerId(), contractChatId); - boolean buyerInContractRoom = - isUserInContractChatRoom(contractChat.getBuyerId(), contractChatId); - - boolean result = ownerInContractRoom && buyerInContractRoom; - log.info( - "최종 전송 가능 여부: {} (owner: {}, buyer: {})", - result, - ownerInContractRoom, - buyerInContractRoom); - - return result; - } catch (Exception e) { - log.error("메시지 전송 가능 여부 확인 실패", e); - return false; - } - } - - /** {@inheritDoc} */ - @Override - @Transactional - public void setContractUserOffline(Long userId, Long contractChatId) { - setContractChatUserOffline(userId, contractChatId); - } - - /** {@inheritDoc} */ - private void setContractChatUserOnline(Long userId, Long contractChatId) { - String key = "contract-chat-" + contractChatId; - contractChatOnlineUsers - .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) - .add(userId); - } - - /** {@inheritDoc} */ - private void setContractChatUserOffline(Long userId, Long contractChatId) { - String key = "contract-chat-" + contractChatId; - Set users = contractChatOnlineUsers.get(key); - if (users != null) { - users.remove(userId); - if (users.isEmpty()) { - contractChatOnlineUsers.remove(key); - } - } - } - - /** {@inheritDoc} */ - private boolean isUserInContractChatRoom(Long userId, Long contractChatId) { - String key = "contract-chat-" + contractChatId; - Set users = contractChatOnlineUsers.get(key); - boolean isOnline = users != null && users.contains(userId); - return isOnline; - } - - /** {@inheritDoc} */ - @Override - @Transactional - public void requestEndPointExport(Long contractChatId, Long ownerId) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!ownerId.equals(contractChat.getOwnerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChatDocument endRequestMessage = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(ownerId) - .receiverId(contractChat.getBuyerId()) - .content("임대인이 특약 대화 종료 및 내보내기를 요청했습니다.") - .sendTime(LocalDateTime.now().toString()) - .build(); - - String key = "contract:request-end:" + contractChatId; - String existingValue = stringRedisTemplate.opsForValue().get(key); - if (existingValue != null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 특약 종료 요청이 진행 중입니다."); - } - String value = ownerId.toString(); - stringRedisTemplate.opsForValue().set(key, value); - - contractChatMessageRepository.saveMessage(endRequestMessage); - - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, endRequestMessage); - } - - /** {@inheritDoc} */ - @Override - public void rejectEndPointExport(Long contractChatId, Long userId) { - String redisKey = "contract:request-end:" + contractChatId; - stringRedisTemplate.delete(redisKey); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - ContractChatDocument rejectNotification = - ContractChatDocument.builder() - .contractChatId(contractChatId.toString()) - .senderId(userId) - .receiverId(contractChat.getBuyerId()) - .content("임차인이 특약 대화를 더 요청했습니다.") - .sendTime(LocalDateTime.now().toString()) - .build(); - - contractChatMessageRepository.saveMessage(rejectNotification); - - messagingTemplate.convertAndSend( - "/topic/contract-chat/" + contractChatId, rejectNotification); - } - - @Override - @Transactional - public void createNextRoundSpecialContractDocument( - Long contractChatId, List rejectedOrders, List passedOrders) { - log.info("=== 새 라운드 SPECIAL_CONTRACT 문서 생성 시작 ==="); - log.info( - "contractChatId: {}, rejectedOrders: {}, passedOrders: {}", - contractChatId, - rejectedOrders, - passedOrders); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - Long currentRound = contractChat.getCurrentRound(); - - SpecialContractDocument latestDocument = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, currentRound) - .orElseThrow( - () -> new IllegalArgumentException("현재 라운드의 특약 문서를 찾을 수 없습니다")); - - Long newRound = currentRound + 1; - log.info("새 라운드: {} → {}", currentRound, newRound); - - List allPassedOrders = new ArrayList<>(passedOrders); - - List completedContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, true); - - for (SpecialContractFixDocument completed : completedContracts) { - if (!allPassedOrders.contains(completed.getOrder())) { - allPassedOrders.add(completed.getOrder()); - log.info("이전 라운드에서 이미 완료된 특약 {}번 추가", completed.getOrder()); - } - } - log.info("최종 통과된 특약들 (이전 완료 포함): {}", allPassedOrders); - - List newClauses = new ArrayList<>(); - - for (int order = 1; order <= 6; order++) { - Integer orderInteger = Integer.valueOf(order); - Long orderLong = Long.valueOf(order); - - if (allPassedOrders.contains(orderLong)) { - Optional clauseOpt = - findBestClauseForOrder(contractChatId, orderLong); - - if (clauseOpt.isPresent()) { - SpecialContractDocument.Clause clause = clauseOpt.get(); - SpecialContractDocument.Clause copiedClause = - SpecialContractDocument.Clause.builder() - .order(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .assessment( - SpecialContractDocument.Assessment.builder() - .owner( - SpecialContractDocument.Evaluation - .builder() - .level( - clause.getAssessment() - .getOwner() - .getLevel()) - .reason( - clause.getAssessment() - .getOwner() - .getReason()) - .build()) - .tenant( - SpecialContractDocument.Evaluation - .builder() - .level( - clause.getAssessment() - .getTenant() - .getLevel()) - .reason( - clause.getAssessment() - .getTenant() - .getReason()) - .build()) - .build()) - .build(); - newClauses.add(copiedClause); - log.info("통과된 특약 {}번 복사 완료", order); - } - } else if (rejectedOrders.contains(orderLong)) { - SpecialContractDocument.Clause emptyClause = - SpecialContractDocument.Clause.builder() - .order(orderInteger) - .title("") - .content("") - .assessment( - SpecialContractDocument.Assessment.builder() - .owner( - SpecialContractDocument.Evaluation.builder() - .level("") - .reason("") - .build()) - .tenant( - SpecialContractDocument.Evaluation.builder() - .level("") - .reason("") - .build()) - .build()) - .build(); - newClauses.add(emptyClause); - log.info("거부된 특약 {}번 빈 껍데기 생성 완료", order); - } else { - latestDocument.getClauses().stream() - .filter(clause -> clause.getOrder().equals(orderInteger)) - .findFirst() - .ifPresent( - clause -> { - SpecialContractDocument.Clause maintainedClause = - SpecialContractDocument.Clause.builder() - .order(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .assessment( - SpecialContractDocument.Assessment - .builder() - .owner( - SpecialContractDocument - .Evaluation - .builder() - .level( - clause.getAssessment() - .getOwner() - .getLevel()) - .reason( - clause.getAssessment() - .getOwner() - .getReason()) - .build()) - .tenant( - SpecialContractDocument - .Evaluation - .builder() - .level( - clause.getAssessment() - .getTenant() - .getLevel()) - .reason( - clause.getAssessment() - .getTenant() - .getReason()) - .build()) - .build()) - .build(); - newClauses.add(maintainedClause); - }); - } - } - - newClauses.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder())); - - SpecialContractDocument newDocument = - SpecialContractDocument.builder() - .contractChatId(contractChatId) - .round(newRound) - .totalClauses(newClauses.size()) - .clauses(newClauses) - .build(); - - specialContractMongoRepository.saveSpecialContractForNewRound(newDocument); - - log.info( - "새 라운드 SPECIAL_CONTRACT 문서 생성 완료 - round: {}, totalClauses: {}", - newRound, - newClauses.size()); - log.info( - "최종 통과된 특약: {}, 거부된 특약: {}, 유지된 특약: {}", - allPassedOrders, - rejectedOrders, - Arrays.asList(1, 2, 3, 4, 5, 6).stream() - .filter( - i -> - !allPassedOrders.contains((long) i) - && !rejectedOrders.contains((long) i)) - .collect(Collectors.toList())); - } - - private Optional findBestClauseForOrder( - Long contractChatId, Long order) { - for (Long round = 4L; round >= 1L; round--) { - Optional docOpt = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, round); - - if (docOpt.isPresent()) { - SpecialContractDocument doc = docOpt.get(); - Optional clauseOpt = - doc.getClauses().stream() - .filter(clause -> clause.getOrder().equals(order.intValue())) - .filter( - clause -> - clause.getTitle() != null - && !clause.getTitle().trim().isEmpty() - && clause.getContent() != null - && !clause.getContent().trim().isEmpty()) - .findFirst(); - - if (clauseOpt.isPresent()) { - log.info("특약 {}번의 최적 조항을 라운드 {}에서 발견", order, round); - return clauseOpt; - } - } - } - - log.warn("특약 {}번의 완성된 조항을 찾을 수 없음", order); - return Optional.empty(); - } - - @Override - @Transactional - public List proceedAllIncompleteToNextRound(Long contractChatId) { - log.info("=== 모든 미완료 특약 다음 라운드 진행 시작 ==="); - log.info("contractChatId: {}", contractChatId); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - Long currentRound = contractChat.getCurrentRound(); - log.info("현재 라운드: {}", currentRound); - - List incompleteContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, false); - - if (incompleteContracts.isEmpty()) { - log.info("진행할 미완료 특약이 없습니다."); - return new ArrayList<>(); - } - - log.info("진행할 특약 개수: {}", incompleteContracts.size()); - - List updatedContracts = new ArrayList<>(); - - for (SpecialContractFixDocument document : incompleteContracts) { - try { - int targetIndex = (int) (document.getRound() - 1); - - if (targetIndex >= 2) { - log.warn("특약 {}번: 최대 라운드 도달, 스킵", document.getOrder()); - continue; - } - - ContentDataDto prevDataToStore = - ContentDataDto.builder() - .title(document.getRecentData().getTitle()) - .content(document.getRecentData().getContent()) - .messages(document.getRecentData().getMessages()) - .build(); - - List updatedPrevData = new ArrayList<>(document.getPrevData()); - updatedPrevData.set(targetIndex, prevDataToStore); - - document.setPrevData(updatedPrevData); - document.setRecentData(createEmptyContentData()); - document.setRound(document.getRound() + 1); - log.info( - "특약 {}번: round {} → {} 증가", - document.getOrder(), - currentRound, - currentRound + 1); - - SpecialContractFixDocument updated = - specialContractMongoRepository.updateSpecialContract(document); - updatedContracts.add(updated); - - log.info("특약 {}번 라운드 진행 완료: round={}", document.getOrder(), updated.getRound()); - - } catch (Exception e) { - log.error("특약 {}번 라운드 진행 실패: {}", document.getOrder(), e.getMessage()); - } - } - - log.info("=== 모든 미완료 특약 다음 라운드 진행 완료 ==="); - log.info("성공적으로 진행된 특약 개수: {}", updatedContracts.size()); - - return updatedContracts; - } - - @Override - @Transactional - public Object submitUserSelection( - Long contractChatId, Long userId, Map selections) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다."); - } - - boolean isOwner = userId.equals(contractChat.getOwnerId()); - boolean isTenant = userId.equals(contractChat.getBuyerId()); - - if (!isOwner && !isTenant) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChat.ContractStatus currentStatus = contractChat.getStatus(); - - List availableOrders = getAvailableOrders(contractChatId, currentStatus); - if (!isValidSelection(selections, availableOrders)) { - throw new IllegalArgumentException("현재 상태에서 선택할 수 없는 특약입니다. 선택 가능: " + availableOrders); - } - Optional existingOpt = - specialContractMongoRepository.findSelectionByContractChatId(contractChatId); - - SpecialContractSelectionDocument document; - if (existingOpt.isPresent()) { - document = existingOpt.get(); - } else { - document = - SpecialContractSelectionDocument.builder() - .contractChatId(contractChatId) - .ownerSelections(new HashMap<>()) - .tenantSelections(new HashMap<>()) - .ownerCompleted(false) - .tenantCompleted(false) - .processed(false) - .build(); - } - - if (isOwner) { - document.setOwnerSelections(selections); - document.setOwnerCompleted(true); - } else { - document.setTenantSelections(selections); - document.setTenantCompleted(true); - } - - specialContractMongoRepository.saveSelectionStatus(document); - - if (!document.isOwnerCompleted() || !document.isTenantCompleted()) { - String waitingFor = isOwner ? "임차인" : "임대인"; - return Map.of("message", "선택을 기다리는 중입니다: " + waitingFor, "completed", false); - } - - if (document.isProcessed()) { - return Map.of("message", "이미 처리된 선택입니다.", "completed", true); - } - AiMessage(contractChatId, "특약 대화가 시작됩니다!"); - return processRoundResults(contractChatId, document, currentStatus, isOwner); - } - - private List getAvailableOrders( - Long contractChatId, ContractChat.ContractStatus status) { - if (status == ContractChat.ContractStatus.STEP0 - || status == ContractChat.ContractStatus.STEP1 - || status == ContractChat.ContractStatus.STEP2) { - return Arrays.asList(1, 2, 3, 4, 5, 6); - } else { - return specialContractMongoRepository - .findByContractChatIdAndIsPassed(contractChatId, false) - .stream() - .map(doc -> doc.getOrder().intValue()) - .collect(Collectors.toList()); - } - } - - @Transactional - public Object processRoundResults( - Long contractChatId, - SpecialContractSelectionDocument document, - ContractChat.ContractStatus currentStatus, - boolean isOwner) { - List rejectedOrders = - findRejectedOrders(document.getOwnerSelections(), document.getTenantSelections()); - List passedOrders = - findPassedOrders(document.getOwnerSelections(), document.getTenantSelections()); - - for (int order = 1; order <= 6; order++) { - Boolean ownerChoice = document.getOwnerSelections().get(order); - Boolean tenantChoice = document.getTenantSelections().get(order); - - if (Boolean.TRUE.equals(ownerChoice) && Boolean.TRUE.equals(tenantChoice)) { - try { - markSpecialContractAsPassed(contractChatId, (long) order); - } catch (Exception e) { - log.warn("특약 {}번 완료 처리 실패", order); - } - } - } - document.setProcessed(true); - specialContractMongoRepository.saveSelectionStatus(document); - - if (currentStatus == ContractChat.ContractStatus.STEP0 - || currentStatus == ContractChat.ContractStatus.STEP1 - || currentStatus == ContractChat.ContractStatus.STEP2) { - - if (rejectedOrders.isEmpty()) { - try { - FinalSpecialContractDocument finalContract = - saveFinalSpecialContract(contractChatId); - - AiMessage(contractChatId, "모든 특약에 동의하셨습니다! 최종 특약서가 생성되었습니다."); - - log.info("초안에서 최종 특약 저장 완료 - finalContractId: {}", finalContract.getId()); - - return Map.of( - "message", - "모든 특약에 동의했습니다.", - "completed", - true, - "finalContractId", - finalContract.getId(), - "totalFinalClauses", - finalContract.getTotalFinalClauses()); - } catch (Exception e) { - log.error("초안에서 최종 특약 저장 실패", e); - return Map.of("message", "모든 특약에 동의했지만 최종 저장 중 오류가 발생했습니다.", "completed", true); - } - } - - List createdOrders = new ArrayList<>(); - for (Long order : rejectedOrders) { - try { - createSpecialContract(contractChatId, order); - createdOrders.add(order); - } catch (IllegalArgumentException e) { - log.warn("특약 {}번이 이미 존재합니다", order); - } - } - - try { - createNextRoundSpecialContractDocument( - contractChatId, rejectedOrders, passedOrders); - } catch (Exception e) { - log.error("새 라운드 SPECIAL_CONTRACT 문서 생성 실패", e); - } - - contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.ROUND0); - resetSelectionDocument(contractChatId); - - return Map.of( - "message", "특약 협상이 시작됩니다.", "completed", true, "createdOrders", createdOrders); - } else { - if (rejectedOrders.isEmpty()) { - try { - FinalSpecialContractDocument finalContract = - saveFinalSpecialContract(contractChatId); - - AiMessageNext(contractChatId, "🎉 모든 특약 협상이 완료되었습니다! 최종 특약서가 생성되었습니다."); - - return Map.of( - "message", - "모든 특약이 완료되었습니다!", - "completed", - true, - "finalContractId", - finalContract.getId(), - "totalFinalClauses", - finalContract.getTotalFinalClauses()); - } catch (Exception e) { - log.error("최종 특약 저장 실패", e); - return Map.of("message", "특약은 완료되었지만 최종 저장 중 오류가 발생했습니다.", "completed", true); - } - } - - try { - createNextRoundSpecialContractDocument( - contractChatId, rejectedOrders, passedOrders); - } catch (Exception e) { - log.error("새 라운드 SPECIAL_CONTRACT 문서 생성 실패", e); - } - - resetSelectionDocument(contractChatId); - - return Map.of("message", "특약 협상이 시작됩니다.", "completed", true); - } - } - - private List findPassedOrders( - Map ownerSelections, Map tenantSelections) { - List passedOrders = new ArrayList<>(); - - for (int order = 1; order <= 6; order++) { - Boolean ownerChoice = ownerSelections.get(order); - Boolean tenantChoice = tenantSelections.get(order); - - if (Boolean.TRUE.equals(ownerChoice) && Boolean.TRUE.equals(tenantChoice)) { - passedOrders.add((long) order); - } - } - - return passedOrders; - } - - private ContractChat.ContractStatus getNextStatus(ContractChat.ContractStatus current) { - switch (current) { - case ROUND0: - return ContractChat.ContractStatus.ROUND1; - case ROUND1: - return ContractChat.ContractStatus.ROUND2; - case ROUND2: - return ContractChat.ContractStatus.ROUND3; - default: - return null; - } - } - - private boolean isValidSelection( - Map selections, List availableOrders) { - return selections.keySet().stream().allMatch(availableOrders::contains); - } - - private void resetSelectionDocument(Long contractChatId) { - Optional opt = - specialContractMongoRepository.findSelectionByContractChatId(contractChatId); - if (opt.isPresent()) { - SpecialContractSelectionDocument doc = opt.get(); - doc.setOwnerSelections(new HashMap<>()); - doc.setTenantSelections(new HashMap<>()); - doc.setOwnerCompleted(false); - doc.setTenantCompleted(false); - doc.setProcessed(false); - specialContractMongoRepository.saveSelectionStatus(doc); - } - } - - @Override - @Transactional - public SpecialContractFixDocument createSpecialContract(Long contractChatId, Long order) { - Optional existing = - specialContractMongoRepository.findByContractChatIdAndOrder(contractChatId, order); - - if (existing.isPresent()) { - throw new IllegalArgumentException( - "이미 존재하는 특약입니다: contractChatId=" + contractChatId + ", order=" + order); - } - - List prevData = new ArrayList<>(); - prevData.add(createEmptyContentData()); - prevData.add(createEmptyContentData()); - - ContentDataDto recentData = createEmptyContentData(); - - SpecialContractFixDocument document = - SpecialContractFixDocument.builder() - .contractChatId(contractChatId) - .order(order) - .round(1L) - .isPassed(false) - .prevData(prevData) - .recentData(recentData) - .build(); - - return specialContractMongoRepository.createSpecialContract(document); - } - - @Override - public Map getAllRoundsSpecialContract(Long contractChatId, Long userId) { - log.info("=== 전체 라운드 특약 문서 조회 시작 ==="); - log.info("contractChatId: {}, userId: {}", contractChatId, userId); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다."); - } - - boolean isOwner = userId.equals(contractChat.getOwnerId()); - boolean isTenant = userId.equals(contractChat.getBuyerId()); - - if (!isOwner && !isTenant) { - throw new IllegalArgumentException("해당 계약 채팅방에 접근 권한이 없습니다."); - } - - String userRole = isOwner ? "owner" : "tenant"; - - Map allRounds = new LinkedHashMap<>(); - int availableRounds = 0; - - for (Long round = 1L; round <= 4L; round++) { - try { - Optional documentOpt = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, round); - - if (documentOpt.isPresent()) { - SpecialContractDocument document = documentOpt.get(); - - List userClauses = - document.getClauses().stream() - .map( - clause -> { - SpecialContractDocument.Evaluation userEvaluation = - isOwner - ? clause.getAssessment().getOwner() - : clause.getAssessment() - .getTenant(); - - return SpecialContractUserViewDto.ClauseUserView - .builder() - .id(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .level(userEvaluation.getLevel()) - .reason(userEvaluation.getReason()) - .build(); - }) - .collect(Collectors.toList()); - - SpecialContractUserViewDto roundData = - SpecialContractUserViewDto.builder() - .contractChatId(document.getContractChatId()) - .round(document.getRound()) - .totalClauses(document.getTotalClauses()) - .userRole(userRole) - .clauses(userClauses) - .build(); - - allRounds.put("round" + round, roundData); - availableRounds++; - - log.info("라운드 {} 조회 완료 - clauses: {}", round, userClauses.size()); - } else { - log.info("라운드 {} 문서 없음", round); - allRounds.put("round" + round, null); - } - } catch (Exception e) { - log.error("라운드 {} 조회 실패: {}", round, e.getMessage()); - allRounds.put("round" + round, null); - } - } - - Map result = new HashMap<>(); - result.put("contractChatId", contractChatId); - result.put("userRole", userRole); - result.put("currentStatus", contractChat.getStatus()); - result.put("availableRounds", availableRounds); - result.put("rounds", allRounds); - - log.info("전체 라운드 특약 문서 조회 완료 - 사용 가능한 라운드: {}", availableRounds); - - return result; - } - - @Override - public SpecialContractUserViewDto getSpecialContractForUserByStatus( - Long contractChatId, Long userId) { - log.info("=== 상태별 특약 문서 조회 시작 ==="); - log.info("contractChatId: {}, userId: {}", contractChatId, userId); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - log.error("계약 채팅방을 찾을 수 없음 - contractChatId: {}", contractChatId); - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다."); - } - - boolean isOwner = userId.equals(contractChat.getOwnerId()); - boolean isTenant = userId.equals(contractChat.getBuyerId()); - - if (!isOwner && !isTenant) { - log.error("접근 권한 없음 - contractChatId: {}, userId: {}", contractChatId, userId); - throw new IllegalArgumentException("해당 계약 채팅방에 접근 권한이 없습니다."); - } - - ContractChat.ContractStatus currentStatus = contractChat.getStatus(); - String userRole = isOwner ? "owner" : "tenant"; - - if (currentStatus == ContractChat.ContractStatus.ROUND1 - || currentStatus == ContractChat.ContractStatus.ROUND2 - || currentStatus == ContractChat.ContractStatus.ROUND3) { - - log.info("ROUND1~3 상태 - 완료되지 않은 특약 문서만 조회: {}", currentStatus); - - List incompleteFixDocs = - getIncompleteSpecialContractsByChat(contractChatId, userId); - - if (incompleteFixDocs.isEmpty()) { - log.warn("완료되지 않은 특약 문서를 찾을 수 없음 - contractChatId: {}", contractChatId); - throw new IllegalArgumentException("완료되지 않은 특약 문서를 찾을 수 없습니다: " + contractChatId); - } - - Set targetOrders = - incompleteFixDocs.stream() - .map(doc -> doc.getOrder().intValue()) - .collect(Collectors.toSet()); - - Long currentRound = getCurrentRoundNumber(currentStatus); - SpecialContractDocument fullDocument = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, currentRound) - .orElseThrow( - () -> { - log.warn( - "라운드 {}의 특약 문서를 찾을 수 없음 - contractChatId: {}", - currentRound, - contractChatId); - return new IllegalArgumentException( - "라운드 " - + currentRound - + "의 특약 문서를 찾을 수 없습니다: " - + contractChatId); - }); - - List userClauses = - fullDocument.getClauses().stream() - .filter(clause -> targetOrders.contains(clause.getOrder())) - .map( - clause -> { - SpecialContractDocument.Evaluation userEvaluation = - isOwner - ? clause.getAssessment().getOwner() - : clause.getAssessment().getTenant(); - - log.debug( - "특약 {} 변환 - title: {}, level: {}", - clause.getOrder(), - clause.getTitle(), - userEvaluation.getLevel()); - - return SpecialContractUserViewDto.ClauseUserView.builder() - .id(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .level(userEvaluation.getLevel()) - .reason(userEvaluation.getReason()) - .build(); - }) - .collect(Collectors.toList()); - - SpecialContractUserViewDto result = - SpecialContractUserViewDto.builder() - .contractChatId(fullDocument.getContractChatId()) - .round(fullDocument.getRound()) - .totalClauses(userClauses.size()) - .userRole(userRole) - .clauses(userClauses) - .build(); - - log.info( - "완료되지 않은 특약 문서 조회 완료 - userRole: {}, clauses: {}, round: {}", - userRole, - userClauses.size(), - fullDocument.getRound()); - - return result; - } - - Long targetRound = determineTargetRound(currentStatus); - log.info("현재 상태: {}, 조회할 라운드: {}", currentStatus, targetRound); - - SpecialContractDocument document = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, targetRound) - .orElseThrow( - () -> { - log.warn( - "라운드 {}의 특약 문서를 찾을 수 없음 - contractChatId: {}", - targetRound, - contractChatId); - return new IllegalArgumentException( - "라운드 " - + targetRound - + "의 특약 문서를 찾을 수 없습니다: " - + contractChatId); - }); - - log.info( - "특약 문서 조회 완료 - round: {}, totalClauses: {}", - document.getRound(), - document.getTotalClauses()); - - log.info("사용자 역할 확인 완료 - userRole: {}", userRole); - - List userClauses = - document.getClauses().stream() - .map( - clause -> { - SpecialContractDocument.Evaluation userEvaluation = - isOwner - ? clause.getAssessment().getOwner() - : clause.getAssessment().getTenant(); - - log.debug( - "특약 {} 변환 - title: {}, level: {}", - clause.getOrder(), - clause.getTitle(), - userEvaluation.getLevel()); - - return SpecialContractUserViewDto.ClauseUserView.builder() - .id(clause.getOrder()) - .title(clause.getTitle()) - .content(clause.getContent()) - .level(userEvaluation.getLevel()) - .reason(userEvaluation.getReason()) - .build(); - }) - .collect(Collectors.toList()); - - SpecialContractUserViewDto result = - SpecialContractUserViewDto.builder() - .contractChatId(document.getContractChatId()) - .round(document.getRound()) - .totalClauses(document.getTotalClauses()) - .userRole(userRole) - .clauses(userClauses) - .build(); - - log.info( - "상태별 특약 문서 조회 완료 - userRole: {}, clauses: {}, round: {}", - userRole, - userClauses.size(), - document.getRound()); - - return result; - } - - private Long getCurrentRoundNumber(ContractChat.ContractStatus status) { - switch (status) { - case ROUND1: - return 2L; - case ROUND2: - return 3L; - case ROUND3: - return 4L; - default: - return 1L; - } - } - - private Long determineTargetRound(ContractChat.ContractStatus status) { - switch (status) { - case STEP0: - case STEP1: - case STEP2: - return 1L; - case ROUND0: - return 1L; - case ROUND1: - return 2L; - case ROUND2: - return 3L; - case ROUND3: - return 4L; - default: - return 1L; - } - } - - @Override - public SpecialContractFixDocument findSpecialContract(Long contractChatId) { - return specialContractMongoRepository - .findByContractChatId(contractChatId) - .orElseThrow( - () -> - new IllegalArgumentException( - "해당 특약 문서를 찾을 수 없습니다: " + contractChatId)); - } - - @Override - public SpecialContractFixDocument updateRecentData( - Long contractChatId, Long order, String messages) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - Long currentRound = contractChat.getCurrentRound(); - - SpecialContractDocument specialContract = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, currentRound) - .orElseThrow( - () -> - new IllegalArgumentException( - "라운드 " - + currentRound - + "의 특약 문서를 찾을 수 없습니다: " - + contractChatId)); - - SpecialContractDocument.Clause targetClause = - specialContract.getClauses().stream() - .filter(clause -> clause.getOrder().equals(order.intValue())) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "해당 order의 특약 조항을 찾을 수 없습니다: " + order)); - SpecialContractFixDocument document = - specialContractMongoRepository - .findByContractChatIdAndOrder(contractChatId, order) - .orElseThrow( - () -> - new IllegalArgumentException( - "해당 특약 문서를 찾을 수 없습니다: contractChatId=" - + contractChatId - + ", order=" - + order)); - - ContentDataDto updatedRecentData = - ContentDataDto.builder() - .title(targetClause.getTitle()) - .content(targetClause.getContent()) - .messages(messages != null ? messages : "") - .build(); - - document.setRecentData(updatedRecentData); - - return specialContractMongoRepository.updateSpecialContract(document); - } - - @Override - public SpecialContractFixDocument markSpecialContractAsPassed(Long contractChatId, Long order) { - SpecialContractFixDocument document = - specialContractMongoRepository - .findByContractChatIdAndOrder(contractChatId, order) - .orElseThrow( - () -> - new IllegalArgumentException( - "특약 문서를 찾을 수 없습니다: contractChatId=" - + contractChatId - + ", order=" - + order)); - - document.setIsPassed(true); - return specialContractMongoRepository.updateSpecialContract(document); - } - - @Override - public List getCompletedSpecialContracts() { - return specialContractMongoRepository.findByIsPassed(true); - } - - @Override - public List getIncompleteSpecialContractsByChat( - Long contractChatId, Long userId) { - if (!isUserInContractChat(contractChatId, userId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - return specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, false); - } - - private ContentDataDto createEmptyContentData() { - return ContentDataDto.builder().title("").content("").messages("").build(); - } - - private List findRejectedOrders( - Map ownerSelections, Map tenantSelections) { - List rejectedOrders = new ArrayList<>(); - - for (int order = 1; order <= 6; order++) { - Boolean ownerChoice = ownerSelections.get(order); - Boolean tenantChoice = tenantSelections.get(order); - - if (Boolean.FALSE.equals(ownerChoice) || Boolean.FALSE.equals(tenantChoice)) { - rejectedOrders.add((long) order); - } - } - - return rejectedOrders; - } - - @Override - @Transactional - public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId) { - log.info("=== 최종 특약 저장 시작 ==="); - log.info("contractChatId: {}", contractChatId); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - SpecialContractDocument latestDocument = null; - Long latestRound = null; - - for (Long round = 4L; round >= 1L; round--) { - Optional docOpt = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, round); - - if (docOpt.isPresent()) { - latestDocument = docOpt.get(); - latestRound = round; - log.info("가장 최근 라운드 발견: {}", round); - break; - } - } - - if (latestDocument == null) { - throw new IllegalStateException("특약 문서를 찾을 수 없습니다: " + contractChatId); - } - - List finalClauses = new ArrayList<>(); - - for (SpecialContractDocument.Clause clause : latestDocument.getClauses()) { - if (clause.getOrder() != null) { - String title = clause.getTitle(); - String content = clause.getContent(); - - if (title != null - && !title.trim().isEmpty() - && content != null - && !content.trim().isEmpty()) { - - FinalSpecialContractDocument.FinalClause finalClause = - FinalSpecialContractDocument.FinalClause.builder() - .order(clause.getOrder()) - .title(title.trim()) - .content(content.trim()) - .build(); - - finalClauses.add(finalClause); - log.info("특약 {}번 저장 완료 (라운드 {}): {}", clause.getOrder(), latestRound, title); - } else { - boolean foundInPreviousRound = false; - for (Long searchRound = latestRound - 1; searchRound >= 1L; searchRound--) { - Optional prevDocOpt = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, searchRound); - - if (prevDocOpt.isPresent()) { - SpecialContractDocument prevDoc = prevDocOpt.get(); - - for (SpecialContractDocument.Clause prevClause : prevDoc.getClauses()) { - if (prevClause.getOrder() != null - && prevClause.getOrder().equals(clause.getOrder())) { - - String prevTitle = prevClause.getTitle(); - String prevContent = prevClause.getContent(); - - if (prevTitle != null - && !prevTitle.trim().isEmpty() - && prevContent != null - && !prevContent.trim().isEmpty()) { - - FinalSpecialContractDocument.FinalClause finalClause = - FinalSpecialContractDocument.FinalClause.builder() - .order(prevClause.getOrder()) - .title(prevTitle.trim()) - .content(prevContent.trim()) - .build(); - - finalClauses.add(finalClause); - log.info( - "특약 {}번 저장 완료 (이전 라운드 {}): {}", - prevClause.getOrder(), - searchRound, - prevTitle); - foundInPreviousRound = true; - break; - } - } - } - - if (foundInPreviousRound) { - break; - } - } - } - - if (!foundInPreviousRound) { - log.info("특약 {}번: 모든 라운드에서 유효한 내용을 찾을 수 없음 - 건너뜀", clause.getOrder()); - } - } - } - } - - finalClauses.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder())); - - log.info("최종 저장될 특약 개수: {}", finalClauses.size()); - for (FinalSpecialContractDocument.FinalClause clause : finalClauses) { - log.info("- 특약 {}번: {}", clause.getOrder(), clause.getTitle()); - } - - FinalSpecialContractDocument finalDocument = - FinalSpecialContractDocument.builder() - .contractChatId(contractChatId) - .totalFinalClauses(finalClauses.size()) - .finalClauses(finalClauses) - .build(); - - FinalSpecialContractDocument savedDocument = - specialContractMongoRepository.saveFinalSpecialContract(finalDocument); - - log.info("=== 최종 특약 저장 완료 ==="); - log.info("저장된 문서 ID: {}", savedDocument.getId()); - log.info("총 특약 개수: {}", savedDocument.getTotalFinalClauses()); - - return savedDocument; - } - - private Optional findLatestRoundForOrder( - Long contractChatId, Long order) { - for (Long round = 4L; round >= 1L; round--) { - Optional doc = - specialContractMongoRepository - .findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, round); - - if (doc.isPresent()) { - boolean hasOrder = - doc.get().getClauses().stream() - .anyMatch( - clause -> - clause.getOrder().equals(order.intValue()) - && !clause.getTitle().isEmpty() - && !clause.getContent().isEmpty()); - - if (hasOrder) { - return doc; - } - } - } - return Optional.empty(); - } - - @Transactional - public void checkAndIncrementRoundIfComplete(Long contractChatId) { - log.info("=== 라운드 완료 체크 시작 ==="); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - ContractChat.ContractStatus currentStatus = contractChat.getStatus(); - - Long nextRoundNumber = getNextRoundNumber(currentStatus); - if (nextRoundNumber == null) { - log.info("더 이상 증가할 라운드가 없음: {}", currentStatus); - - if (currentStatus == ContractChat.ContractStatus.ROUND3) { - checkFinalRoundCompletion(contractChatId); - } - return; - } - - log.info("현재 상태: {}, 체크할 라운드: {}", currentStatus, nextRoundNumber); - - Optional documentOpt = - specialContractMongoRepository.findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, nextRoundNumber); - - if (documentOpt.isEmpty()) { - log.warn("라운드 {}의 문서를 찾을 수 없음", nextRoundNumber); - return; - } - - SpecialContractDocument document = documentOpt.get(); - - List incompleteContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, false); - - if (incompleteContracts.isEmpty()) { - log.info("미완료 특약이 없어서 라운드 증가 체크 불필요"); - return; - } - - Set incompleteOrders = - incompleteContracts.stream() - .map(doc -> doc.getOrder().intValue()) - .collect(Collectors.toSet()); - - log.info("미완료 특약 번호들: {}", incompleteOrders); - - boolean allIncompleteClausesAreFilled = - incompleteOrders.stream().allMatch(order -> isClauseFilled(document, order)); - - log.info("모든 미완료 특약이 꽉 찼는지: {}", allIncompleteClausesAreFilled); - - if (allIncompleteClausesAreFilled) { - ContractChat.ContractStatus nextStatus = getNextStatus(currentStatus); - if (nextStatus != null) { - contractChatMapper.updateStatus(contractChatId, nextStatus); - log.info("라운드 자동 증가: {} → {}", currentStatus, nextStatus); - String aimsg = getRoundIncrementMessage(nextStatus); - AiMessageBtn(contractChatId, aimsg); - - if (nextStatus == ContractChat.ContractStatus.ROUND3) { - checkFinalRoundCompletion(contractChatId); - } - } - } else { - log.info("아직 모든 특약이 꽉 차지 않아서 라운드 유지"); - } - } - - @Transactional - public void checkFinalRoundCompletion(Long contractChatId) { - log.info("=== 최종 라운드(4차) 완료 체크 시작 ==="); - - Optional round4DocOpt = - specialContractMongoRepository.findSpecialContractDocumentByContractChatIdAndRound( - contractChatId, 4L); - - if (round4DocOpt.isEmpty()) { - log.info("4차 라운드 문서가 아직 없음"); - return; - } - - SpecialContractDocument round4Document = round4DocOpt.get(); - - List incompleteContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, false); - - if (incompleteContracts.isEmpty()) { - log.info("이미 모든 특약이 완료됨"); - return; - } - - Set incompleteOrders = - incompleteContracts.stream() - .map(doc -> doc.getOrder().intValue()) - .collect(Collectors.toSet()); - - log.info("미완료 특약 번호들: {}", incompleteOrders); - - boolean allFinalClausesAreFilled = - incompleteOrders.stream().allMatch(order -> isClauseFilled(round4Document, order)); - - log.info("4차 라운드 모든 미완료 특약이 작성됨: {}", allFinalClausesAreFilled); - - if (allFinalClausesAreFilled) { - log.info("🎉 모든 특약 협상이 완료되었습니다! 자동으로 완료 처리합니다."); - - for (SpecialContractFixDocument incompleteContract : incompleteContracts) { - try { - markSpecialContractAsPassed(contractChatId, incompleteContract.getOrder()); - log.info("특약 {}번 자동 완료 처리", incompleteContract.getOrder()); - } catch (Exception e) { - log.error("특약 {}번 완료 처리 실패: {}", incompleteContract.getOrder(), e.getMessage()); - } - } - - try { - FinalSpecialContractDocument finalContract = - saveFinalSpecialContract(contractChatId); - - AiMessageNext( - contractChatId, - "🎉 3차 수정까지 모든 특약 협상이 완료되었습니다! " - + "최종 특약서가 자동으로 생성되었습니다. " - + "총 " - + finalContract.getTotalFinalClauses() - + "개의 특약이 확정되었습니다."); - - log.info( - "최종 특약 자동 저장 완료 - finalContractId: {}, 총 {}개 조항", - finalContract.getId(), - finalContract.getTotalFinalClauses()); - - } catch (Exception e) { - log.error("최종 특약 자동 저장 실패", e); - AiMessage( - contractChatId, - "모든 특약 협상이 완료되었지만 최종 특약서 생성 중 오류가 발생했습니다. " + "관리자에게 문의해주세요."); - } - } else { - log.info("아직 4차 라운드의 모든 특약이 작성되지 않음"); - } - } - - private boolean isClauseFilled(SpecialContractDocument document, Integer order) { - return document.getClauses().stream() - .filter(clause -> Objects.equals(clause.getOrder(), order)) - .findFirst() - .map( - clause -> { - String title = clause.getTitle(); - String content = clause.getContent(); - - boolean titleFilled = title != null && !title.trim().isEmpty(); - boolean contentFilled = content != null && !content.trim().isEmpty(); - boolean isFilled = titleFilled && contentFilled; - - log.info("🔍 특약 {}번 상세 체크:", order); - log.info(" - title 원본: '{}'", title); - log.info(" - title 길이: {}", title != null ? title.length() : "null"); - log.info( - " - title trim 후: '{}'", - title != null ? title.trim() : "null"); - log.info(" - title filled: {}", titleFilled); - - log.info(" - content 원본: '{}'", content); - log.info( - " - content 길이: {}", - content != null ? content.length() : "null"); - log.info( - " - content trim 후: '{}'", - content != null ? content.trim() : "null"); - log.info(" - content filled: {}", contentFilled); - - log.info(" - 최종 결과: {}", isFilled); - - return isFilled; - }) - .orElseGet( - () -> { - log.warn("⚠️ 특약 {}번을 문서에서 찾을 수 없음!", order); - return false; - }); - } - - private Long getNextRoundNumber(ContractChat.ContractStatus status) { - switch (status) { - case ROUND0: - return 2L; - case ROUND1: - return 3L; - case ROUND2: - return 4L; - case ROUND3: - return null; - default: - return null; - } - } - - private String getRoundIncrementMessage(ContractChat.ContractStatus status) { - switch (status) { - case ROUND1: - return "1차 수정이 완료되었습니다! 2차 협상 라운드가 시작됩니다."; - case ROUND2: - return "2차 수정이 완료되었습니다! 3차 협상 라운드가 시작됩니다."; - case ROUND3: - return "3차 수정이 완료되었습니다! 최종 협상 라운드가 시작됩니다."; - default: - return "새로운 협상 라운드가 시작됩니다."; - } - } - - @Override - @Transactional - public ModificationRequestData requestFinalContractModification( - Long contractChatId, Long ownerId, FinalContractModificationRequestDto requestDto) { - - log.info("=== 최종 특약서 수정 요청 시작 ==="); - log.info( - "contractChatId: {}, ownerId: {}, clauseOrder: {}", - contractChatId, - ownerId, - requestDto.getClauseOrder()); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!ownerId.equals(contractChat.getOwnerId())) { - throw new BusinessException( - ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 수정 요청할 수 있습니다."); - } - - Optional finalContractOpt = - specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); - - if (finalContractOpt.isEmpty()) { - throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); - } - - FinalSpecialContractDocument finalContract = finalContractOpt.get(); - boolean clauseExists = - finalContract.getFinalClauses().stream() - .anyMatch(clause -> clause.getOrder().equals(requestDto.getClauseOrder())); - - if (!clauseExists) { - throw new IllegalArgumentException( - "해당 특약 조항을 찾을 수 없습니다: " + requestDto.getClauseOrder()); - } - - String redisKey = - "final-contract:modification:" + contractChatId + ":" + requestDto.getClauseOrder(); - - String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); - if (existingRequest != null) { - throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); - } - - ModificationRequestData requestData = - ModificationRequestData.builder() - .contractChatId(contractChatId) - .clauseOrder(requestDto.getClauseOrder()) - .newTitle(requestDto.getNewTitle()) - .newContent(requestDto.getNewContent()) - .requesterId(ownerId) - .createdAt(LocalDateTime.now().toString()) - .build(); - - try { - String jsonData = objectMapper.writeValueAsString(requestData); - stringRedisTemplate.opsForValue().set(redisKey, jsonData, Duration.ofHours(24)); - - String notificationMessage = - String.format( - "임대인이 특약 %d번 수정을 요청했습니다.\n\n" + "📝 수정 제목: %s\n" + "✏️ 수정 내용: %s\n\n", - requestDto.getClauseOrder(), - requestDto.getNewTitle(), - requestDto.getNewContent()); - - AiMessageBtn(contractChatId, notificationMessage); - log.info("수정 요청 Redis 저장 완료 - key: {}", redisKey); - return requestData; - - } catch (Exception e) { - log.error("수정 요청 저장 실패", e); - throw new RuntimeException("수정 요청 저장 중 오류가 발생했습니다."); - } - } - - @Override - @Transactional - public FinalSpecialContractDocument respondToModificationRequest( - Long contractChatId, Long buyerId, FinalContractModificationResponseDto responseDto) { - - log.info("=== 수정 요청 응답 처리 시작 ==="); - log.info( - "contractChatId: {}, buyerId: {}, accepted: {}", - contractChatId, - buyerId, - responseDto.isAccepted()); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!buyerId.equals(contractChat.getBuyerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); - } - - String redisKey = - "final-contract:modification:" - + contractChatId - + ":" - + responseDto.getClauseOrder(); - String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); - - if (requestDataJson == null) { - throw new IllegalArgumentException("해당 조항에 대한 대기중인 수정 요청이 없습니다."); - } - - try { - ModificationRequestData requestData = - objectMapper.readValue(requestDataJson, ModificationRequestData.class); - - FinalSpecialContractDocument finalContract = - specialContractMongoRepository - .findFinalContractByContractChatId(contractChatId) - .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); - - String resultMessage; - - if (responseDto.isAccepted()) { - List updatedClauses = - finalContract.getFinalClauses().stream() - .map( - clause -> { - if (clause.getOrder() - .equals(responseDto.getClauseOrder())) { - return FinalSpecialContractDocument.FinalClause - .builder() - .order(clause.getOrder()) - .title(requestData.getNewTitle()) - .content(requestData.getNewContent()) - .build(); - } - return clause; - }) - .collect(Collectors.toList()); - - finalContract.setFinalClauses(updatedClauses); - - specialContractMongoRepository.saveFinalSpecialContract(finalContract); - - resultMessage = - String.format( - "임차인이 특약 %d번 수정 요청을 수락했습니다. 특약이 변경되었습니다.", - responseDto.getClauseOrder()); - - log.info("수정 수락 - 최종 특약서 업데이트 완료"); - - } else { - resultMessage = - String.format( - "임차인이 특약 %d번 수정 요청을 거절했습니다. 기존 특약이 유지됩니다.", - responseDto.getClauseOrder()); - - log.info("수정 거절 - 기존 특약서 유지"); - } - - stringRedisTemplate.delete(redisKey); - - AiMessage(contractChatId, resultMessage); - return finalContract; - - } catch (Exception e) { - log.error("수정 요청 응답 처리 실패", e); - throw new RuntimeException("응답 처리 중 오류가 발생했습니다."); - } - } - - @Override - public ModificationRequestData getPendingModificationRequest( - Long contractChatId, Integer clauseOrder) { - String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; - String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); - - if (requestDataJson == null) { - return null; - } - - try { - return objectMapper.readValue(requestDataJson, ModificationRequestData.class); - } catch (Exception e) { - log.error("수정 요청 데이터 파싱 실패", e); - return null; - } - } - - @Override - public boolean hasPendingModificationRequest(Long contractChatId, Integer clauseOrder) { - String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; - return stringRedisTemplate.hasKey(redisKey); - } - - @Override - @Transactional - public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!ownerId.equals(contractChat.getOwnerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - Optional finalContractOpt = - specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); - - if (finalContractOpt.isEmpty()) { - throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); - } - - AiMessage(contractChatId, "특약을 수락하였습니다"); - - String key = "final-contract:confirmation:" + contractChatId; - String existingValue = stringRedisTemplate.opsForValue().get(key); - if (existingValue != null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다."); - } - String value = ownerId.toString(); - stringRedisTemplate.opsForValue().set(key, value); - } - - @Override - @Transactional - public Map acceptFinalContractConfirmation(Long contractChatId, Long buyerId) { - if (!isUserInContractChat(contractChatId, buyerId)) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - Long ownerId = contractChat.getOwnerId(); - - if (!buyerId.equals(contractChat.getBuyerId())) { - throw new BusinessException( - ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다."); - } - - String redisKey = "final-contract:confirmation:" + contractChatId; - String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); - - if (storedOwnerId == null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다."); - } - - if (!storedOwnerId.equals(ownerId.toString())) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다."); - } - - FinalSpecialContractDocument finalContract = - specialContractMongoRepository - .findFinalContractByContractChatId(contractChatId) - .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); - - contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP4); - - stringRedisTemplate.delete(redisKey); - - String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; - - AiMessage(contractChatId, confirmationMessage); - - return Map.of( - "message", - "최종 특약서가 확정되었습니다.", - "status", - "COMPLETED", - "finalContractId", - finalContract.getId(), - "totalFinalClauses", - finalContract.getTotalFinalClauses()); - } - - @Override - public void rejectFinalContractConfirmation(Long contractChatId, Long buyerId) { - String redisKey = "final-contract:confirmation:" + contractChatId; - stringRedisTemplate.delete(redisKey); - - AiMessage(contractChatId, "임차인이 수정을 거절하였습니다."); - } - - @Override - @Transactional - public void requestFinalContractDeletion( - Long contractChatId, Long ownerId, Integer clauseOrder) { - log.info("=== 최종 특약 삭제 요청 시작 ==="); - log.info( - "contractChatId: {}, ownerId: {}, clauseOrder: {}", - contractChatId, - ownerId, - clauseOrder); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!ownerId.equals(contractChat.getOwnerId())) { - throw new BusinessException( - ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 삭제 요청할 수 있습니다."); - } - - Optional finalContractOpt = - specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); - - if (finalContractOpt.isEmpty()) { - throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); - } - - FinalSpecialContractDocument finalContract = finalContractOpt.get(); - boolean clauseExists = - finalContract.getFinalClauses().stream() - .anyMatch(clause -> clause.getOrder().equals(clauseOrder)); - - if (!clauseExists) { - throw new IllegalArgumentException("해당 특약 조항을 찾을 수 없습니다: " + clauseOrder); - } - - String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; - - String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); - if (existingRequest != null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 삭제 요청이 진행 중입니다."); - } - - stringRedisTemplate.opsForValue().set(redisKey, ownerId.toString(), Duration.ofHours(24)); - - String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); - - AiMessage(contractChatId, notificationMessage); - - log.info("삭제 요청 Redis 저장 완료 - key: {}, value: {}", redisKey, ownerId); - } - - @Override - @Transactional - public Map acceptFinalContractDeletion( - Long contractChatId, Long buyerId, Integer clauseOrder) { - log.info("=== 최종 특약 삭제 수락 처리 시작 ==="); - log.info( - "contractChatId: {}, buyerId: {}, clauseOrder: {}", - contractChatId, - buyerId, - clauseOrder); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!buyerId.equals(contractChat.getBuyerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); - } - - String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; - String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); - - if (storedOwnerId == null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); - } - - if (!storedOwnerId.equals(contractChat.getOwnerId().toString())) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "삭제 요청 정보가 유효하지 않습니다."); - } - - FinalSpecialContractDocument finalContract = - specialContractMongoRepository - .findFinalContractByContractChatId(contractChatId) - .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); - - List updatedClauses = - finalContract.getFinalClauses().stream() - .filter(clause -> !clause.getOrder().equals(clauseOrder)) - .collect(Collectors.toList()); - - finalContract.setFinalClauses(updatedClauses); - finalContract.setTotalFinalClauses(updatedClauses.size()); - - specialContractMongoRepository.saveFinalSpecialContract(finalContract); - - stringRedisTemplate.delete(redisKey); - - String confirmationMessage = - String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); - - AiMessage(contractChatId, confirmationMessage); - - log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); - - return Map.of( - "message", - "특약이 삭제되었습니다.", - "deletedClauseOrder", - clauseOrder, - "finalContractId", - finalContract.getId(), - "remainingClauses", - finalContract.getTotalFinalClauses()); - } - - @Override - @Transactional - public void rejectFinalContractDeletion( - Long contractChatId, Long buyerId, Integer clauseOrder) { - log.info("=== 최종 특약 삭제 거절 처리 시작 ==="); - log.info( - "contractChatId: {}, buyerId: {}, clauseOrder: {}", - contractChatId, - buyerId, - clauseOrder); - - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - if (contractChat == null) { - throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); - } - - if (!buyerId.equals(contractChat.getBuyerId())) { - throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); - } - - String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; - String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); - - if (storedOwnerId == null) { - throw new BusinessException( - ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); - } - - stringRedisTemplate.delete(redisKey); - - String rejectionMessage = - String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); - - AiMessage(contractChatId, rejectionMessage); - - log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); - } -} \ No newline at end of file + private final ContractChatMapper contractChatMapper; + private final ChatRoomMapper chatRoomMapper; + private final ContractChatMessageRepository contractChatMessageRepository; + private final SimpMessagingTemplate messagingTemplate; + @Lazy private final ChatServiceInterface chatService; + private final AiClauseImproveService aiClauseImproveService; + private final PreContractDataService preContractDataService; + + private final Map> contractChatOnlineUsers = new ConcurrentHashMap<>(); + private final RedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + @Autowired private SpecialContractMongoRepository specialContractMongoRepository; + + /** {@inheritDoc} */ + @Override + @Transactional + public Long createContractChat(Long chatRoomId, Long userId) { + ChatRoom chatRoom = chatRoomMapper.findById(chatRoomId); + if (chatRoom == null) { + throw new EntityNotFoundException("채팅방을 찾을 수 없습니다: " + chatRoomId); + } + + if (!chatService.isUserInChatRoom(chatRoomId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat existingContract = + contractChatMapper.findByUserAndHome( + chatRoom.getOwnerId(), chatRoom.getBuyerId(), chatRoom.getHomeId()); + + if (existingContract != null) { + return existingContract.getContractChatId(); + } + + ContractChat contractChat = new ContractChat(); + contractChat.setHomeId(chatRoom.getHomeId()); + contractChat.setOwnerId(chatRoom.getOwnerId()); + contractChat.setBuyerId(chatRoom.getBuyerId()); + contractChat.setContractStartAt(LocalDateTime.now()); + contractChat.setLastMessage("계약이 시작되었습니다."); + + contractChatMapper.createContractChat(contractChat); + + return contractChat.getContractChatId(); + } + + /** {@inheritDoc} */ + @Override + @Transactional + public void handleContractChatMessage(ContractChatMessageRequestDto dto) { + if (dto.getContractChatId() == null || dto.getContent() == null) { + throw new IllegalArgumentException("필수 파라미터가 누락되었습니다."); + } + + if (!isUserInContractChat(dto.getContractChatId(), dto.getSenderId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChatDocument messageDocument = + ContractChatDocument.builder() + .contractChatId(dto.getContractChatId().toString()) + .senderId(dto.getSenderId()) + .receiverId(dto.getReceiverId()) + .content(dto.getContent()) + .sendTime(LocalDateTime.now().toString()) + .build(); + + try { + ContractChatDocument savedMessage = + contractChatMessageRepository.saveMessage(messageDocument); + + contractChatMapper.updateLastMessage(dto.getContractChatId(), dto.getContent()); + + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + dto.getContractChatId(), savedMessage); + + } catch (Exception e) { + log.error("메시지 처리 중 오류 발생", e); + throw e; + } + } + + public void AiMessage(Long contractChatId, String content) { + final Long ai = 9999L; + + ContractChatDocument aiMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ai) + .receiverId(null) + .content(content) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(aiMessage); + contractChatMapper.updateLastMessage(contractChatId, content); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); + } + + public void AiMessageNext(Long contractChatId, String content) { + final Long ai = 9997L; + + ContractChatDocument aiMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ai) + .receiverId(null) + .content(content) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(aiMessage); + contractChatMapper.updateLastMessage(contractChatId, content); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); + } + + public void AiMessageBtn(Long contractChatId, String content) { + final Long ai = 9998L; + + ContractChatDocument aiMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ai) + .receiverId(null) + .content(content) + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(aiMessage); + contractChatMapper.updateLastMessage(contractChatId, content); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); + } + + /** {@inheritDoc} */ + @Override + public List getContractMessages(Long contractChatId) { + return contractChatMessageRepository.getMessages(contractChatId); + } + + /** 스타트 버튼 클릭 - 현재 시간을 시작점으로 설정 */ + @Override + @Transactional + public String setStartPoint(Long contractChatId, Long userId) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + contractChatMapper.clearTimePoints(contractChatId); + String startTime = LocalDateTime.now().toString(); + + contractChatMapper.updateStartTime(contractChatId, startTime); + + return startTime; + } + + /** {@inheritDoc} */ + @Override + public ContractChat getContractChatInfo(Long contractChatId, Long userId) { + if (contractChatId == null || userId == null) { + throw new IllegalArgumentException("contractChatId와 userId는 null일 수 없습니다."); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!userId.equals(contractChat.getOwnerId()) + && !userId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + return contractChat; + } + + /** {@inheritDoc} */ + @Override + @Transactional + public boolean setEndPointAndExport(Long contractChatId, Long userId, Long order) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + Long ownerId = contractChat.getOwnerId(); + Long buyerId = contractChat.getBuyerId(); + + if (!userId.equals(buyerId)) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "구매자만 특약 내보내기를 수락할 수 있습니다."); + } + + String redisKey = "contract:request-end:" + contractChatId; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "특약 종료 요청이 존재하지 않습니다."); + } + + if (!storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "특약 종료 요청 정보가 유효하지 않습니다."); + } + + String startTime = contractChat.getStartPoint(); + if (startTime == null || startTime.trim().isEmpty()) { + throw new BusinessException(ChatErrorCode.START_POINT_NOT_SET); + } + + String endTime = LocalDateTime.now().toString(); + contractChatMapper.updateEndTime(contractChatId, endTime); + + List exportMessages = + contractChatMessageRepository.getMessagesBetweenTime( + contractChatId, startTime, endTime); + + StringBuilder sb = new StringBuilder(); + if (exportMessages != null && !exportMessages.isEmpty()) { + for (ContractChatDocument msg : exportMessages) { + Long senderId = msg.getSenderId(); + String content = msg.getContent(); + + if (!content.equals("임대인이 특약 대화 종료 및 내보내기를 요청했습니다.") + && !content.equals("임차인이 특약 대화를 더 요청했습니다.")) { + + String senderRole = + senderId.equals(buyerId) + ? "구매자" + : senderId.equals(ownerId) ? "판매자" : "조회실패"; + String toai = String.format("%s: %s", senderRole, content); + sb.append(toai).append("\n"); + } + } + sb.append("특약 대화가 종료되었습니다."); + } else { + sb.append("조회된 특약 메시지가 없습니다."); + } + + stringRedisTemplate.delete(redisKey); + + String result = sb.toString(); + + SpecialContractFixDocument improveClauseRequest = + updateRecentData(contractChatId, order, result); + ClauseImproveResponseDto improveClauseResponse = getAiClauseImprove(improveClauseRequest); + + updateSpecialClause(contractChatId, improveClauseResponse); + + checkAndIncrementRoundIfComplete(contractChatId); + return true; + } + + private boolean isRejectedClause(Long contractChatId, Long order) { + try { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + Long currentRound = contractChat.getCurrentRound(); + + SpecialContractDocument currentDocument = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, currentRound) + .orElse(null); + + if (currentDocument == null) { + log.warn("현재 라운드 문서를 찾을 수 없음: round {}", currentRound); + return false; + } + + Optional clauseOpt = + currentDocument.getClauses().stream() + .filter(clause -> clause.getOrder().equals(order.intValue())) + .findFirst(); + + if (clauseOpt.isEmpty()) { + log.warn("특약 {}번을 현재 라운드에서 찾을 수 없음", order); + return false; + } + + SpecialContractDocument.Clause clause = clauseOpt.get(); + + boolean isEmpty = + (clause.getTitle() == null || clause.getTitle().trim().isEmpty()) + && (clause.getContent() == null + || clause.getContent().trim().isEmpty()); + + log.info( + "특약 {}번 상태 체크 - title: '{}', content: '{}', 거부된 특약: {}", + order, + clause.getTitle(), + clause.getContent(), + isEmpty); + + return isEmpty; + + } catch (Exception e) { + log.error("특약 {}번 거부 상태 체크 실패: {}", order, e.getMessage()); + return false; + } + } + + private void updateSpecialClause(Long contractChatId, ClauseImproveResponseDto response) { + + Long round = response.getData().getRound(); + Integer order = response.getData().getOrder(); + String content = response.getData().getContent(); + String title = response.getData().getTitle(); + + SpecialContractDocument.Assessment assessment = + SpecialContractDocument.Assessment.builder() + .owner( + SpecialContractDocument.Evaluation.builder() + .level( + response.getData() + .getAssessment() + .getOwner() + .getLevel()) + .reason( + response.getData() + .getAssessment() + .getOwner() + .getReason()) + .build()) + .tenant( + SpecialContractDocument.Evaluation.builder() + .level( + response.getData() + .getAssessment() + .getTenant() + .getLevel()) + .reason( + response.getData() + .getAssessment() + .getTenant() + .getReason()) + .build()) + .build(); + + SpecialContractDocument.Clause clause = + SpecialContractDocument.Clause.builder() + .order(order) + .title(title) + .content(content) + .assessment(assessment) + .build(); + + String id = + specialContractMongoRepository.updateSpecialContractForNewOrderAndRound( + contractChatId, round, order, clause); + } + + private ClauseImproveResponseDto getAiClauseImprove(SpecialContractFixDocument scfd) { + + Long contractChatId = scfd.getContractChatId(); + // 1. Owner 데이터 조회 + ClauseImproveRequestDto.OwnerData ownerData = + preContractDataService.fetchOwnerData(contractChatId); + + // 2. Tenant 데이터 조회 + ClauseImproveRequestDto.TenantData tenantData = + preContractDataService.fetchTenantData(contractChatId); + + // 3. OCR 데이터 조회 + ClauseImproveRequestDto.OcrData ocrData = + preContractDataService.fetchOcrData(contractChatId); + + // 4. 이전 특약 데이터 설정 (테스트용) + List prevClauses = scfd.getPrevData(); + + // 5. 최근 특약 데이터 설정 (테스트용) + ContentDataDto recentClause = scfd.getRecentData(); + + // 6. AI 특약 개선 요청 + ClauseImproveRequestDto aiRequest = + ClauseImproveRequestDto.builder() + .contractChatId(contractChatId) + .ocrData(ocrData) + .round(scfd.getRound()) + .order(scfd.getOrder()) + .ownerData(ownerData) + .tenantData(tenantData) + .prevData(scfd.getPrevData()) + .recentData(scfd.getRecentData()) + .build(); + + return aiClauseImproveService.improveClause(aiRequest); + } + + /** {@inheritDoc} */ + @Override + public boolean isUserInContractChat(Long contractChatId, Long userId) { + if (contractChatId == null || userId == null) { + return false; + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + return false; + } + + return userId.equals(contractChat.getOwnerId()) || userId.equals(contractChat.getBuyerId()); + } + + /** {@inheritDoc} */ + @Override + @Transactional + public void enterContractChatRoom(Long contractChatId, Long userId) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + setContractChatUserOnline(userId, contractChatId); + } + + /** {@inheritDoc} */ + @Override + @Transactional + public void leaveContractChatRoom(Long contractChatId, Long userId) { + setContractChatUserOffline(userId, contractChatId); + } + + /** {@inheritDoc} */ + @Override + public Map getContractChatOnlineStatus(Long contractChatId, Long userId) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + boolean ownerInContractRoom = + isUserInContractChatRoom(contractChat.getOwnerId(), contractChatId); + boolean buyerInContractRoom = + isUserInContractChatRoom(contractChat.getBuyerId(), contractChatId); + + boolean bothInRoom = ownerInContractRoom && buyerInContractRoom; + + return Map.of( + "ownerInContractRoom", ownerInContractRoom, + "buyerInContractRoom", buyerInContractRoom, + "bothInRoom", bothInRoom, + "canChat", bothInRoom, + "ownerId", contractChat.getOwnerId(), + "buyerId", contractChat.getBuyerId()); + } + + /** {@inheritDoc} */ + @Override + public boolean canSendContractMessage(Long contractChatId) { + try { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + return false; + } + + boolean ownerInContractRoom = + isUserInContractChatRoom(contractChat.getOwnerId(), contractChatId); + boolean buyerInContractRoom = + isUserInContractChatRoom(contractChat.getBuyerId(), contractChatId); + + boolean result = ownerInContractRoom && buyerInContractRoom; + log.info( + "최종 전송 가능 여부: {} (owner: {}, buyer: {})", + result, + ownerInContractRoom, + buyerInContractRoom); + + return result; + } catch (Exception e) { + log.error("메시지 전송 가능 여부 확인 실패", e); + return false; + } + } + + /** {@inheritDoc} */ + @Override + @Transactional + public void setContractUserOffline(Long userId, Long contractChatId) { + setContractChatUserOffline(userId, contractChatId); + } + + /** {@inheritDoc} */ + private void setContractChatUserOnline(Long userId, Long contractChatId) { + String key = "contract-chat-" + contractChatId; + contractChatOnlineUsers + .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) + .add(userId); + } + + /** {@inheritDoc} */ + private void setContractChatUserOffline(Long userId, Long contractChatId) { + String key = "contract-chat-" + contractChatId; + Set users = contractChatOnlineUsers.get(key); + if (users != null) { + users.remove(userId); + if (users.isEmpty()) { + contractChatOnlineUsers.remove(key); + } + } + } + + /** {@inheritDoc} */ + private boolean isUserInContractChatRoom(Long userId, Long contractChatId) { + String key = "contract-chat-" + contractChatId; + Set users = contractChatOnlineUsers.get(key); + boolean isOnline = users != null && users.contains(userId); + return isOnline; + } + + /** {@inheritDoc} */ + @Override + @Transactional + public void requestEndPointExport(Long contractChatId, Long ownerId) { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChatDocument endRequestMessage = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(ownerId) + .receiverId(contractChat.getBuyerId()) + .content("임대인이 특약 대화 종료 및 내보내기를 요청했습니다.") + .sendTime(LocalDateTime.now().toString()) + .build(); + + String key = "contract:request-end:" + contractChatId; + String existingValue = stringRedisTemplate.opsForValue().get(key); + if (existingValue != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 특약 종료 요청이 진행 중입니다."); + } + String value = ownerId.toString(); + stringRedisTemplate.opsForValue().set(key, value); + + contractChatMessageRepository.saveMessage(endRequestMessage); + + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, endRequestMessage); + } + + /** {@inheritDoc} */ + @Override + public void rejectEndPointExport(Long contractChatId, Long userId) { + String redisKey = "contract:request-end:" + contractChatId; + stringRedisTemplate.delete(redisKey); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + ContractChatDocument rejectNotification = + ContractChatDocument.builder() + .contractChatId(contractChatId.toString()) + .senderId(userId) + .receiverId(contractChat.getBuyerId()) + .content("임차인이 특약 대화를 더 요청했습니다.") + .sendTime(LocalDateTime.now().toString()) + .build(); + + contractChatMessageRepository.saveMessage(rejectNotification); + + messagingTemplate.convertAndSend( + "/topic/contract-chat/" + contractChatId, rejectNotification); + } + + @Override + @Transactional + public void createNextRoundSpecialContractDocument( + Long contractChatId, List rejectedOrders, List passedOrders) { + log.info("=== 새 라운드 SPECIAL_CONTRACT 문서 생성 시작 ==="); + log.info( + "contractChatId: {}, rejectedOrders: {}, passedOrders: {}", + contractChatId, + rejectedOrders, + passedOrders); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + Long currentRound = contractChat.getCurrentRound(); + + SpecialContractDocument latestDocument = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, currentRound) + .orElseThrow( + () -> new IllegalArgumentException("현재 라운드의 특약 문서를 찾을 수 없습니다")); + + Long newRound = currentRound + 1; + log.info("새 라운드: {} → {}", currentRound, newRound); + + List allPassedOrders = new ArrayList<>(passedOrders); + + List completedContracts = + specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, true); + + for (SpecialContractFixDocument completed : completedContracts) { + if (!allPassedOrders.contains(completed.getOrder())) { + allPassedOrders.add(completed.getOrder()); + log.info("이전 라운드에서 이미 완료된 특약 {}번 추가", completed.getOrder()); + } + } + log.info("최종 통과된 특약들 (이전 완료 포함): {}", allPassedOrders); + + List newClauses = new ArrayList<>(); + + for (int order = 1; order <= 6; order++) { + Integer orderInteger = Integer.valueOf(order); + Long orderLong = Long.valueOf(order); + + if (allPassedOrders.contains(orderLong)) { + Optional clauseOpt = + findBestClauseForOrder(contractChatId, orderLong); + + if (clauseOpt.isPresent()) { + SpecialContractDocument.Clause clause = clauseOpt.get(); + SpecialContractDocument.Clause copiedClause = + SpecialContractDocument.Clause.builder() + .order(clause.getOrder()) + .title(clause.getTitle()) + .content(clause.getContent()) + .assessment( + SpecialContractDocument.Assessment.builder() + .owner( + SpecialContractDocument.Evaluation + .builder() + .level( + clause.getAssessment() + .getOwner() + .getLevel()) + .reason( + clause.getAssessment() + .getOwner() + .getReason()) + .build()) + .tenant( + SpecialContractDocument.Evaluation + .builder() + .level( + clause.getAssessment() + .getTenant() + .getLevel()) + .reason( + clause.getAssessment() + .getTenant() + .getReason()) + .build()) + .build()) + .build(); + newClauses.add(copiedClause); + log.info("통과된 특약 {}번 복사 완료", order); + } + } else if (rejectedOrders.contains(orderLong)) { + SpecialContractDocument.Clause emptyClause = + SpecialContractDocument.Clause.builder() + .order(orderInteger) + .title("") + .content("") + .assessment( + SpecialContractDocument.Assessment.builder() + .owner( + SpecialContractDocument.Evaluation.builder() + .level("") + .reason("") + .build()) + .tenant( + SpecialContractDocument.Evaluation.builder() + .level("") + .reason("") + .build()) + .build()) + .build(); + newClauses.add(emptyClause); + log.info("거부된 특약 {}번 빈 껍데기 생성 완료", order); + } else { + latestDocument.getClauses().stream() + .filter(clause -> clause.getOrder().equals(orderInteger)) + .findFirst() + .ifPresent( + clause -> { + SpecialContractDocument.Clause maintainedClause = + SpecialContractDocument.Clause.builder() + .order(clause.getOrder()) + .title(clause.getTitle()) + .content(clause.getContent()) + .assessment( + SpecialContractDocument.Assessment + .builder() + .owner( + SpecialContractDocument + .Evaluation + .builder() + .level( + clause.getAssessment() + .getOwner() + .getLevel()) + .reason( + clause.getAssessment() + .getOwner() + .getReason()) + .build()) + .tenant( + SpecialContractDocument + .Evaluation + .builder() + .level( + clause.getAssessment() + .getTenant() + .getLevel()) + .reason( + clause.getAssessment() + .getTenant() + .getReason()) + .build()) + .build()) + .build(); + newClauses.add(maintainedClause); + }); + } + } + + newClauses.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder())); + + SpecialContractDocument newDocument = + SpecialContractDocument.builder() + .contractChatId(contractChatId) + .round(newRound) + .totalClauses(newClauses.size()) + .clauses(newClauses) + .build(); + + specialContractMongoRepository.saveSpecialContractForNewRound(newDocument); + + log.info( + "새 라운드 SPECIAL_CONTRACT 문서 생성 완료 - round: {}, totalClauses: {}", + newRound, + newClauses.size()); + log.info( + "최종 통과된 특약: {}, 거부된 특약: {}, 유지된 특약: {}", + allPassedOrders, + rejectedOrders, + Arrays.asList(1, 2, 3, 4, 5, 6).stream() + .filter( + i -> + !allPassedOrders.contains((long) i) + && !rejectedOrders.contains((long) i)) + .collect(Collectors.toList())); + } + + private Optional findBestClauseForOrder( + Long contractChatId, Long order) { + for (Long round = 4L; round >= 1L; round--) { + Optional docOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, round); + + if (docOpt.isPresent()) { + SpecialContractDocument doc = docOpt.get(); + Optional clauseOpt = + doc.getClauses().stream() + .filter(clause -> clause.getOrder().equals(order.intValue())) + .filter( + clause -> + clause.getTitle() != null + && !clause.getTitle().trim().isEmpty() + && clause.getContent() != null + && !clause.getContent().trim().isEmpty()) + .findFirst(); + + if (clauseOpt.isPresent()) { + log.info("특약 {}번의 최적 조항을 라운드 {}에서 발견", order, round); + return clauseOpt; + } + } + } + + log.warn("특약 {}번의 완성된 조항을 찾을 수 없음", order); + return Optional.empty(); + } + + @Override + @Transactional + public List proceedAllIncompleteToNextRound(Long contractChatId) { + log.info("=== 모든 미완료 특약 다음 라운드 진행 시작 ==="); + log.info("contractChatId: {}", contractChatId); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + Long currentRound = contractChat.getCurrentRound(); + log.info("현재 라운드: {}", currentRound); + + List incompleteContracts = + specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, false); + + if (incompleteContracts.isEmpty()) { + log.info("진행할 미완료 특약이 없습니다."); + return new ArrayList<>(); + } + + log.info("진행할 특약 개수: {}", incompleteContracts.size()); + + List updatedContracts = new ArrayList<>(); + + for (SpecialContractFixDocument document : incompleteContracts) { + try { + int targetIndex = (int) (document.getRound() - 1); + + if (targetIndex >= 2) { + log.warn("특약 {}번: 최대 라운드 도달, 스킵", document.getOrder()); + continue; + } + + ContentDataDto prevDataToStore = + ContentDataDto.builder() + .title(document.getRecentData().getTitle()) + .content(document.getRecentData().getContent()) + .messages(document.getRecentData().getMessages()) + .build(); + + List updatedPrevData = new ArrayList<>(document.getPrevData()); + updatedPrevData.set(targetIndex, prevDataToStore); + + document.setPrevData(updatedPrevData); + document.setRecentData(createEmptyContentData()); + document.setRound(document.getRound() + 1); + log.info( + "특약 {}번: round {} → {} 증가", + document.getOrder(), + currentRound, + currentRound + 1); + + SpecialContractFixDocument updated = + specialContractMongoRepository.updateSpecialContract(document); + updatedContracts.add(updated); + + log.info("특약 {}번 라운드 진행 완료: round={}", document.getOrder(), updated.getRound()); + + } catch (Exception e) { + log.error("특약 {}번 라운드 진행 실패: {}", document.getOrder(), e.getMessage()); + } + } + + log.info("=== 모든 미완료 특약 다음 라운드 진행 완료 ==="); + log.info("성공적으로 진행된 특약 개수: {}", updatedContracts.size()); + + return updatedContracts; + } + + @Override + @Transactional + public Object submitUserSelection( + Long contractChatId, Long userId, Map selections) { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다."); + } + + boolean isOwner = userId.equals(contractChat.getOwnerId()); + boolean isTenant = userId.equals(contractChat.getBuyerId()); + + if (!isOwner && !isTenant) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat.ContractStatus currentStatus = contractChat.getStatus(); + + List availableOrders = getAvailableOrders(contractChatId, currentStatus); + if (!isValidSelection(selections, availableOrders)) { + throw new IllegalArgumentException("현재 상태에서 선택할 수 없는 특약입니다. 선택 가능: " + availableOrders); + } + Optional existingOpt = + specialContractMongoRepository.findSelectionByContractChatId(contractChatId); + + SpecialContractSelectionDocument document; + if (existingOpt.isPresent()) { + document = existingOpt.get(); + } else { + document = + SpecialContractSelectionDocument.builder() + .contractChatId(contractChatId) + .ownerSelections(new HashMap<>()) + .tenantSelections(new HashMap<>()) + .ownerCompleted(false) + .tenantCompleted(false) + .processed(false) + .build(); + } + + if (isOwner) { + document.setOwnerSelections(selections); + document.setOwnerCompleted(true); + } else { + document.setTenantSelections(selections); + document.setTenantCompleted(true); + } + + specialContractMongoRepository.saveSelectionStatus(document); + + if (!document.isOwnerCompleted() || !document.isTenantCompleted()) { + String waitingFor = isOwner ? "임차인" : "임대인"; + return Map.of("message", "선택을 기다리는 중입니다: " + waitingFor, "completed", false); + } + + if (document.isProcessed()) { + return Map.of("message", "이미 처리된 선택입니다.", "completed", true); + } + AiMessage(contractChatId, "특약 대화가 시작됩니다!"); + return processRoundResults(contractChatId, document, currentStatus, isOwner); + } + + private List getAvailableOrders( + Long contractChatId, ContractChat.ContractStatus status) { + if (status == ContractChat.ContractStatus.STEP0 + || status == ContractChat.ContractStatus.STEP1 + || status == ContractChat.ContractStatus.STEP2) { + return Arrays.asList(1, 2, 3, 4, 5, 6); + } else { + return specialContractMongoRepository + .findByContractChatIdAndIsPassed(contractChatId, false) + .stream() + .map(doc -> doc.getOrder().intValue()) + .collect(Collectors.toList()); + } + } + + @Transactional + public Object processRoundResults( + Long contractChatId, + SpecialContractSelectionDocument document, + ContractChat.ContractStatus currentStatus, + boolean isOwner) { + List rejectedOrders = + findRejectedOrders(document.getOwnerSelections(), document.getTenantSelections()); + List passedOrders = + findPassedOrders(document.getOwnerSelections(), document.getTenantSelections()); + + for (int order = 1; order <= 6; order++) { + Boolean ownerChoice = document.getOwnerSelections().get(order); + Boolean tenantChoice = document.getTenantSelections().get(order); + + if (Boolean.TRUE.equals(ownerChoice) && Boolean.TRUE.equals(tenantChoice)) { + try { + markSpecialContractAsPassed(contractChatId, (long) order); + } catch (Exception e) { + log.warn("특약 {}번 완료 처리 실패", order); + } + } + } + document.setProcessed(true); + specialContractMongoRepository.saveSelectionStatus(document); + + if (currentStatus == ContractChat.ContractStatus.STEP0 + || currentStatus == ContractChat.ContractStatus.STEP1 + || currentStatus == ContractChat.ContractStatus.STEP2) { + + if (rejectedOrders.isEmpty()) { + try { + FinalSpecialContractDocument finalContract = + saveFinalSpecialContract(contractChatId); + + AiMessage(contractChatId, "모든 특약에 동의하셨습니다! 최종 특약서가 생성되었습니다."); + + log.info("초안에서 최종 특약 저장 완료 - finalContractId: {}", finalContract.getId()); + + return Map.of( + "message", + "모든 특약에 동의했습니다.", + "completed", + true, + "finalContractId", + finalContract.getId(), + "totalFinalClauses", + finalContract.getTotalFinalClauses()); + } catch (Exception e) { + log.error("초안에서 최종 특약 저장 실패", e); + return Map.of("message", "모든 특약에 동의했지만 최종 저장 중 오류가 발생했습니다.", "completed", true); + } + } + + List createdOrders = new ArrayList<>(); + for (Long order : rejectedOrders) { + try { + createSpecialContract(contractChatId, order); + createdOrders.add(order); + } catch (IllegalArgumentException e) { + log.warn("특약 {}번이 이미 존재합니다", order); + } + } + + try { + createNextRoundSpecialContractDocument( + contractChatId, rejectedOrders, passedOrders); + } catch (Exception e) { + log.error("새 라운드 SPECIAL_CONTRACT 문서 생성 실패", e); + } + + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.ROUND0); + resetSelectionDocument(contractChatId); + + return Map.of( + "message", "특약 협상이 시작됩니다.", "completed", true, "createdOrders", createdOrders); + } else { + if (rejectedOrders.isEmpty()) { + try { + FinalSpecialContractDocument finalContract = + saveFinalSpecialContract(contractChatId); + + AiMessageNext(contractChatId, "🎉 모든 특약 협상이 완료되었습니다! 최종 특약서가 생성되었습니다."); + + return Map.of( + "message", + "모든 특약이 완료되었습니다!", + "completed", + true, + "finalContractId", + finalContract.getId(), + "totalFinalClauses", + finalContract.getTotalFinalClauses()); + } catch (Exception e) { + log.error("최종 특약 저장 실패", e); + return Map.of("message", "특약은 완료되었지만 최종 저장 중 오류가 발생했습니다.", "completed", true); + } + } + + try { + createNextRoundSpecialContractDocument( + contractChatId, rejectedOrders, passedOrders); + } catch (Exception e) { + log.error("새 라운드 SPECIAL_CONTRACT 문서 생성 실패", e); + } + + resetSelectionDocument(contractChatId); + + return Map.of("message", "특약 협상이 시작됩니다.", "completed", true); + } + } + + private List findPassedOrders( + Map ownerSelections, Map tenantSelections) { + List passedOrders = new ArrayList<>(); + + for (int order = 1; order <= 6; order++) { + Boolean ownerChoice = ownerSelections.get(order); + Boolean tenantChoice = tenantSelections.get(order); + + if (Boolean.TRUE.equals(ownerChoice) && Boolean.TRUE.equals(tenantChoice)) { + passedOrders.add((long) order); + } + } + + return passedOrders; + } + + private ContractChat.ContractStatus getNextStatus(ContractChat.ContractStatus current) { + switch (current) { + case ROUND0: + return ContractChat.ContractStatus.ROUND1; + case ROUND1: + return ContractChat.ContractStatus.ROUND2; + case ROUND2: + return ContractChat.ContractStatus.ROUND3; + default: + return null; + } + } + + private boolean isValidSelection( + Map selections, List availableOrders) { + return selections.keySet().stream().allMatch(availableOrders::contains); + } + + private void resetSelectionDocument(Long contractChatId) { + Optional opt = + specialContractMongoRepository.findSelectionByContractChatId(contractChatId); + if (opt.isPresent()) { + SpecialContractSelectionDocument doc = opt.get(); + doc.setOwnerSelections(new HashMap<>()); + doc.setTenantSelections(new HashMap<>()); + doc.setOwnerCompleted(false); + doc.setTenantCompleted(false); + doc.setProcessed(false); + specialContractMongoRepository.saveSelectionStatus(doc); + } + } + + @Override + @Transactional + public SpecialContractFixDocument createSpecialContract(Long contractChatId, Long order) { + Optional existing = + specialContractMongoRepository.findByContractChatIdAndOrder(contractChatId, order); + + if (existing.isPresent()) { + throw new IllegalArgumentException( + "이미 존재하는 특약입니다: contractChatId=" + contractChatId + ", order=" + order); + } + + List prevData = new ArrayList<>(); + prevData.add(createEmptyContentData()); + prevData.add(createEmptyContentData()); + + ContentDataDto recentData = createEmptyContentData(); + + SpecialContractFixDocument document = + SpecialContractFixDocument.builder() + .contractChatId(contractChatId) + .order(order) + .round(1L) + .isPassed(false) + .prevData(prevData) + .recentData(recentData) + .build(); + + return specialContractMongoRepository.createSpecialContract(document); + } + + @Override + public Map getAllRoundsSpecialContract(Long contractChatId, Long userId) { + log.info("=== 전체 라운드 특약 문서 조회 시작 ==="); + log.info("contractChatId: {}, userId: {}", contractChatId, userId); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다."); + } + + boolean isOwner = userId.equals(contractChat.getOwnerId()); + boolean isTenant = userId.equals(contractChat.getBuyerId()); + + if (!isOwner && !isTenant) { + throw new IllegalArgumentException("해당 계약 채팅방에 접근 권한이 없습니다."); + } + + String userRole = isOwner ? "owner" : "tenant"; + + Map allRounds = new LinkedHashMap<>(); + int availableRounds = 0; + + for (Long round = 1L; round <= 4L; round++) { + try { + Optional documentOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, round); + + if (documentOpt.isPresent()) { + SpecialContractDocument document = documentOpt.get(); + + List userClauses = + document.getClauses().stream() + .map( + clause -> { + SpecialContractDocument.Evaluation userEvaluation = + isOwner + ? clause.getAssessment().getOwner() + : clause.getAssessment() + .getTenant(); + + return SpecialContractUserViewDto.ClauseUserView + .builder() + .id(clause.getOrder()) + .title(clause.getTitle()) + .content(clause.getContent()) + .level(userEvaluation.getLevel()) + .reason(userEvaluation.getReason()) + .build(); + }) + .collect(Collectors.toList()); + + SpecialContractUserViewDto roundData = + SpecialContractUserViewDto.builder() + .contractChatId(document.getContractChatId()) + .round(document.getRound()) + .totalClauses(document.getTotalClauses()) + .userRole(userRole) + .clauses(userClauses) + .build(); + + allRounds.put("round" + round, roundData); + availableRounds++; + + log.info("라운드 {} 조회 완료 - clauses: {}", round, userClauses.size()); + } else { + log.info("라운드 {} 문서 없음", round); + allRounds.put("round" + round, null); + } + } catch (Exception e) { + log.error("라운드 {} 조회 실패: {}", round, e.getMessage()); + allRounds.put("round" + round, null); + } + } + + Map result = new HashMap<>(); + result.put("contractChatId", contractChatId); + result.put("userRole", userRole); + result.put("currentStatus", contractChat.getStatus()); + result.put("availableRounds", availableRounds); + result.put("rounds", allRounds); + + log.info("전체 라운드 특약 문서 조회 완료 - 사용 가능한 라운드: {}", availableRounds); + + return result; + } + + @Override + public SpecialContractUserViewDto getSpecialContractForUserByStatus( + Long contractChatId, Long userId) { + log.info("=== 상태별 특약 문서 조회 시작 ==="); + log.info("contractChatId: {}, userId: {}", contractChatId, userId); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + log.error("계약 채팅방을 찾을 수 없음 - contractChatId: {}", contractChatId); + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다."); + } + + boolean isOwner = userId.equals(contractChat.getOwnerId()); + boolean isTenant = userId.equals(contractChat.getBuyerId()); + + if (!isOwner && !isTenant) { + log.error("접근 권한 없음 - contractChatId: {}, userId: {}", contractChatId, userId); + throw new IllegalArgumentException("해당 계약 채팅방에 접근 권한이 없습니다."); + } + + ContractChat.ContractStatus currentStatus = contractChat.getStatus(); + String userRole = isOwner ? "owner" : "tenant"; + + if (currentStatus == ContractChat.ContractStatus.ROUND1 + || currentStatus == ContractChat.ContractStatus.ROUND2 + || currentStatus == ContractChat.ContractStatus.ROUND3) { + + log.info("ROUND1~3 상태 - 완료되지 않은 특약 문서만 조회: {}", currentStatus); + + List incompleteFixDocs = + getIncompleteSpecialContractsByChat(contractChatId, userId); + + if (incompleteFixDocs.isEmpty()) { + log.warn("완료되지 않은 특약 문서를 찾을 수 없음 - contractChatId: {}", contractChatId); + throw new IllegalArgumentException("완료되지 않은 특약 문서를 찾을 수 없습니다: " + contractChatId); + } + + Set targetOrders = + incompleteFixDocs.stream() + .map(doc -> doc.getOrder().intValue()) + .collect(Collectors.toSet()); + + Long currentRound = getCurrentRoundNumber(currentStatus); + SpecialContractDocument fullDocument = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, currentRound) + .orElseThrow( + () -> { + log.warn( + "라운드 {}의 특약 문서를 찾을 수 없음 - contractChatId: {}", + currentRound, + contractChatId); + return new IllegalArgumentException( + "라운드 " + + currentRound + + "의 특약 문서를 찾을 수 없습니다: " + + contractChatId); + }); + + List userClauses = + fullDocument.getClauses().stream() + .filter(clause -> targetOrders.contains(clause.getOrder())) + .map( + clause -> { + SpecialContractDocument.Evaluation userEvaluation = + isOwner + ? clause.getAssessment().getOwner() + : clause.getAssessment().getTenant(); + + log.debug( + "특약 {} 변환 - title: {}, level: {}", + clause.getOrder(), + clause.getTitle(), + userEvaluation.getLevel()); + + return SpecialContractUserViewDto.ClauseUserView.builder() + .id(clause.getOrder()) + .title(clause.getTitle()) + .content(clause.getContent()) + .level(userEvaluation.getLevel()) + .reason(userEvaluation.getReason()) + .build(); + }) + .collect(Collectors.toList()); + + SpecialContractUserViewDto result = + SpecialContractUserViewDto.builder() + .contractChatId(fullDocument.getContractChatId()) + .round(fullDocument.getRound()) + .totalClauses(userClauses.size()) + .userRole(userRole) + .clauses(userClauses) + .build(); + + log.info( + "완료되지 않은 특약 문서 조회 완료 - userRole: {}, clauses: {}, round: {}", + userRole, + userClauses.size(), + fullDocument.getRound()); + + return result; + } + + Long targetRound = determineTargetRound(currentStatus); + log.info("현재 상태: {}, 조회할 라운드: {}", currentStatus, targetRound); + + SpecialContractDocument document = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, targetRound) + .orElseThrow( + () -> { + log.warn( + "라운드 {}의 특약 문서를 찾을 수 없음 - contractChatId: {}", + targetRound, + contractChatId); + return new IllegalArgumentException( + "라운드 " + + targetRound + + "의 특약 문서를 찾을 수 없습니다: " + + contractChatId); + }); + + log.info( + "특약 문서 조회 완료 - round: {}, totalClauses: {}", + document.getRound(), + document.getTotalClauses()); + + log.info("사용자 역할 확인 완료 - userRole: {}", userRole); + + List userClauses = + document.getClauses().stream() + .map( + clause -> { + SpecialContractDocument.Evaluation userEvaluation = + isOwner + ? clause.getAssessment().getOwner() + : clause.getAssessment().getTenant(); + + log.debug( + "특약 {} 변환 - title: {}, level: {}", + clause.getOrder(), + clause.getTitle(), + userEvaluation.getLevel()); + + return SpecialContractUserViewDto.ClauseUserView.builder() + .id(clause.getOrder()) + .title(clause.getTitle()) + .content(clause.getContent()) + .level(userEvaluation.getLevel()) + .reason(userEvaluation.getReason()) + .build(); + }) + .collect(Collectors.toList()); + + SpecialContractUserViewDto result = + SpecialContractUserViewDto.builder() + .contractChatId(document.getContractChatId()) + .round(document.getRound()) + .totalClauses(document.getTotalClauses()) + .userRole(userRole) + .clauses(userClauses) + .build(); + + log.info( + "상태별 특약 문서 조회 완료 - userRole: {}, clauses: {}, round: {}", + userRole, + userClauses.size(), + document.getRound()); + + return result; + } + + private Long getCurrentRoundNumber(ContractChat.ContractStatus status) { + switch (status) { + case ROUND1: + return 2L; + case ROUND2: + return 3L; + case ROUND3: + return 4L; + default: + return 1L; + } + } + + private Long determineTargetRound(ContractChat.ContractStatus status) { + switch (status) { + case STEP0: + case STEP1: + case STEP2: + return 1L; + case ROUND0: + return 1L; + case ROUND1: + return 2L; + case ROUND2: + return 3L; + case ROUND3: + return 4L; + default: + return 1L; + } + } + + @Override + public SpecialContractFixDocument findSpecialContract(Long contractChatId) { + return specialContractMongoRepository + .findByContractChatId(contractChatId) + .orElseThrow( + () -> + new IllegalArgumentException( + "해당 특약 문서를 찾을 수 없습니다: " + contractChatId)); + } + + @Override + public SpecialContractFixDocument updateRecentData( + Long contractChatId, Long order, String messages) { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + Long currentRound = contractChat.getCurrentRound(); + + SpecialContractDocument specialContract = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, currentRound) + .orElseThrow( + () -> + new IllegalArgumentException( + "라운드 " + + currentRound + + "의 특약 문서를 찾을 수 없습니다: " + + contractChatId)); + + SpecialContractDocument.Clause targetClause = + specialContract.getClauses().stream() + .filter(clause -> clause.getOrder().equals(order.intValue())) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "해당 order의 특약 조항을 찾을 수 없습니다: " + order)); + SpecialContractFixDocument document = + specialContractMongoRepository + .findByContractChatIdAndOrder(contractChatId, order) + .orElseThrow( + () -> + new IllegalArgumentException( + "해당 특약 문서를 찾을 수 없습니다: contractChatId=" + + contractChatId + + ", order=" + + order)); + + ContentDataDto updatedRecentData = + ContentDataDto.builder() + .title(targetClause.getTitle()) + .content(targetClause.getContent()) + .messages(messages != null ? messages : "") + .build(); + + document.setRecentData(updatedRecentData); + + return specialContractMongoRepository.updateSpecialContract(document); + } + + @Override + public SpecialContractFixDocument markSpecialContractAsPassed(Long contractChatId, Long order) { + SpecialContractFixDocument document = + specialContractMongoRepository + .findByContractChatIdAndOrder(contractChatId, order) + .orElseThrow( + () -> + new IllegalArgumentException( + "특약 문서를 찾을 수 없습니다: contractChatId=" + + contractChatId + + ", order=" + + order)); + + document.setIsPassed(true); + return specialContractMongoRepository.updateSpecialContract(document); + } + + @Override + public List getCompletedSpecialContracts() { + return specialContractMongoRepository.findByIsPassed(true); + } + + @Override + public List getIncompleteSpecialContractsByChat( + Long contractChatId, Long userId) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + return specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, false); + } + + private ContentDataDto createEmptyContentData() { + return ContentDataDto.builder().title("").content("").messages("").build(); + } + + private List findRejectedOrders( + Map ownerSelections, Map tenantSelections) { + List rejectedOrders = new ArrayList<>(); + + for (int order = 1; order <= 6; order++) { + Boolean ownerChoice = ownerSelections.get(order); + Boolean tenantChoice = tenantSelections.get(order); + + if (Boolean.FALSE.equals(ownerChoice) || Boolean.FALSE.equals(tenantChoice)) { + rejectedOrders.add((long) order); + } + } + + return rejectedOrders; + } + + @Override + @Transactional + public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId) { + log.info("=== 최종 특약 저장 시작 ==="); + log.info("contractChatId: {}", contractChatId); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + SpecialContractDocument latestDocument = null; + Long latestRound = null; + + for (Long round = 4L; round >= 1L; round--) { + Optional docOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, round); + + if (docOpt.isPresent()) { + latestDocument = docOpt.get(); + latestRound = round; + log.info("가장 최근 라운드 발견: {}", round); + break; + } + } + + if (latestDocument == null) { + throw new IllegalStateException("특약 문서를 찾을 수 없습니다: " + contractChatId); + } + + List finalClauses = new ArrayList<>(); + + for (SpecialContractDocument.Clause clause : latestDocument.getClauses()) { + if (clause.getOrder() != null) { + String title = clause.getTitle(); + String content = clause.getContent(); + + if (title != null + && !title.trim().isEmpty() + && content != null + && !content.trim().isEmpty()) { + + FinalSpecialContractDocument.FinalClause finalClause = + FinalSpecialContractDocument.FinalClause.builder() + .order(clause.getOrder()) + .title(title.trim()) + .content(content.trim()) + .build(); + + finalClauses.add(finalClause); + log.info("특약 {}번 저장 완료 (라운드 {}): {}", clause.getOrder(), latestRound, title); + } else { + boolean foundInPreviousRound = false; + for (Long searchRound = latestRound - 1; searchRound >= 1L; searchRound--) { + Optional prevDocOpt = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, searchRound); + + if (prevDocOpt.isPresent()) { + SpecialContractDocument prevDoc = prevDocOpt.get(); + + for (SpecialContractDocument.Clause prevClause : prevDoc.getClauses()) { + if (prevClause.getOrder() != null + && prevClause.getOrder().equals(clause.getOrder())) { + + String prevTitle = prevClause.getTitle(); + String prevContent = prevClause.getContent(); + + if (prevTitle != null + && !prevTitle.trim().isEmpty() + && prevContent != null + && !prevContent.trim().isEmpty()) { + + FinalSpecialContractDocument.FinalClause finalClause = + FinalSpecialContractDocument.FinalClause.builder() + .order(prevClause.getOrder()) + .title(prevTitle.trim()) + .content(prevContent.trim()) + .build(); + + finalClauses.add(finalClause); + log.info( + "특약 {}번 저장 완료 (이전 라운드 {}): {}", + prevClause.getOrder(), + searchRound, + prevTitle); + foundInPreviousRound = true; + break; + } + } + } + + if (foundInPreviousRound) { + break; + } + } + } + + if (!foundInPreviousRound) { + log.info("특약 {}번: 모든 라운드에서 유효한 내용을 찾을 수 없음 - 건너뜀", clause.getOrder()); + } + } + } + } + + finalClauses.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder())); + + log.info("최종 저장될 특약 개수: {}", finalClauses.size()); + for (FinalSpecialContractDocument.FinalClause clause : finalClauses) { + log.info("- 특약 {}번: {}", clause.getOrder(), clause.getTitle()); + } + + FinalSpecialContractDocument finalDocument = + FinalSpecialContractDocument.builder() + .contractChatId(contractChatId) + .totalFinalClauses(finalClauses.size()) + .finalClauses(finalClauses) + .build(); + + FinalSpecialContractDocument savedDocument = + specialContractMongoRepository.saveFinalSpecialContract(finalDocument); + + log.info("=== 최종 특약 저장 완료 ==="); + log.info("저장된 문서 ID: {}", savedDocument.getId()); + log.info("총 특약 개수: {}", savedDocument.getTotalFinalClauses()); + + return savedDocument; + } + + private Optional findLatestRoundForOrder( + Long contractChatId, Long order) { + for (Long round = 4L; round >= 1L; round--) { + Optional doc = + specialContractMongoRepository + .findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, round); + + if (doc.isPresent()) { + boolean hasOrder = + doc.get().getClauses().stream() + .anyMatch( + clause -> + clause.getOrder().equals(order.intValue()) + && !clause.getTitle().isEmpty() + && !clause.getContent().isEmpty()); + + if (hasOrder) { + return doc; + } + } + } + return Optional.empty(); + } + + @Transactional + public void checkAndIncrementRoundIfComplete(Long contractChatId) { + log.info("=== 라운드 완료 체크 시작 ==="); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + ContractChat.ContractStatus currentStatus = contractChat.getStatus(); + + Long nextRoundNumber = getNextRoundNumber(currentStatus); + if (nextRoundNumber == null) { + log.info("더 이상 증가할 라운드가 없음: {}", currentStatus); + + if (currentStatus == ContractChat.ContractStatus.ROUND3) { + checkFinalRoundCompletion(contractChatId); + } + return; + } + + log.info("현재 상태: {}, 체크할 라운드: {}", currentStatus, nextRoundNumber); + + Optional documentOpt = + specialContractMongoRepository.findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, nextRoundNumber); + + if (documentOpt.isEmpty()) { + log.warn("라운드 {}의 문서를 찾을 수 없음", nextRoundNumber); + return; + } + + SpecialContractDocument document = documentOpt.get(); + + List incompleteContracts = + specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, false); + + if (incompleteContracts.isEmpty()) { + log.info("미완료 특약이 없어서 라운드 증가 체크 불필요"); + return; + } + + Set incompleteOrders = + incompleteContracts.stream() + .map(doc -> doc.getOrder().intValue()) + .collect(Collectors.toSet()); + + log.info("미완료 특약 번호들: {}", incompleteOrders); + + boolean allIncompleteClausesAreFilled = + incompleteOrders.stream().allMatch(order -> isClauseFilled(document, order)); + + log.info("모든 미완료 특약이 꽉 찼는지: {}", allIncompleteClausesAreFilled); + + if (allIncompleteClausesAreFilled) { + ContractChat.ContractStatus nextStatus = getNextStatus(currentStatus); + if (nextStatus != null) { + contractChatMapper.updateStatus(contractChatId, nextStatus); + log.info("라운드 자동 증가: {} → {}", currentStatus, nextStatus); + String aimsg = getRoundIncrementMessage(nextStatus); + AiMessageBtn(contractChatId, aimsg); + + if (nextStatus == ContractChat.ContractStatus.ROUND3) { + checkFinalRoundCompletion(contractChatId); + } + } + } else { + log.info("아직 모든 특약이 꽉 차지 않아서 라운드 유지"); + } + } + + @Transactional + public void checkFinalRoundCompletion(Long contractChatId) { + log.info("=== 최종 라운드(4차) 완료 체크 시작 ==="); + + Optional round4DocOpt = + specialContractMongoRepository.findSpecialContractDocumentByContractChatIdAndRound( + contractChatId, 4L); + + if (round4DocOpt.isEmpty()) { + log.info("4차 라운드 문서가 아직 없음"); + return; + } + + SpecialContractDocument round4Document = round4DocOpt.get(); + + List incompleteContracts = + specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, false); + + if (incompleteContracts.isEmpty()) { + log.info("이미 모든 특약이 완료됨"); + return; + } + + Set incompleteOrders = + incompleteContracts.stream() + .map(doc -> doc.getOrder().intValue()) + .collect(Collectors.toSet()); + + log.info("미완료 특약 번호들: {}", incompleteOrders); + + boolean allFinalClausesAreFilled = + incompleteOrders.stream().allMatch(order -> isClauseFilled(round4Document, order)); + + log.info("4차 라운드 모든 미완료 특약이 작성됨: {}", allFinalClausesAreFilled); + + if (allFinalClausesAreFilled) { + log.info("🎉 모든 특약 협상이 완료되었습니다! 자동으로 완료 처리합니다."); + + for (SpecialContractFixDocument incompleteContract : incompleteContracts) { + try { + markSpecialContractAsPassed(contractChatId, incompleteContract.getOrder()); + log.info("특약 {}번 자동 완료 처리", incompleteContract.getOrder()); + } catch (Exception e) { + log.error("특약 {}번 완료 처리 실패: {}", incompleteContract.getOrder(), e.getMessage()); + } + } + + try { + FinalSpecialContractDocument finalContract = + saveFinalSpecialContract(contractChatId); + + AiMessageNext( + contractChatId, + "🎉 3차 수정까지 모든 특약 협상이 완료되었습니다! " + + "최종 특약서가 자동으로 생성되었습니다. " + + "총 " + + finalContract.getTotalFinalClauses() + + "개의 특약이 확정되었습니다."); + + log.info( + "최종 특약 자동 저장 완료 - finalContractId: {}, 총 {}개 조항", + finalContract.getId(), + finalContract.getTotalFinalClauses()); + + } catch (Exception e) { + log.error("최종 특약 자동 저장 실패", e); + AiMessage( + contractChatId, + "모든 특약 협상이 완료되었지만 최종 특약서 생성 중 오류가 발생했습니다. " + "관리자에게 문의해주세요."); + } + } else { + log.info("아직 4차 라운드의 모든 특약이 작성되지 않음"); + } + } + + private boolean isClauseFilled(SpecialContractDocument document, Integer order) { + return document.getClauses().stream() + .filter(clause -> Objects.equals(clause.getOrder(), order)) + .findFirst() + .map( + clause -> { + String title = clause.getTitle(); + String content = clause.getContent(); + + boolean titleFilled = title != null && !title.trim().isEmpty(); + boolean contentFilled = content != null && !content.trim().isEmpty(); + boolean isFilled = titleFilled && contentFilled; + + log.info("🔍 특약 {}번 상세 체크:", order); + log.info(" - title 원본: '{}'", title); + log.info(" - title 길이: {}", title != null ? title.length() : "null"); + log.info( + " - title trim 후: '{}'", + title != null ? title.trim() : "null"); + log.info(" - title filled: {}", titleFilled); + + log.info(" - content 원본: '{}'", content); + log.info( + " - content 길이: {}", + content != null ? content.length() : "null"); + log.info( + " - content trim 후: '{}'", + content != null ? content.trim() : "null"); + log.info(" - content filled: {}", contentFilled); + + log.info(" - 최종 결과: {}", isFilled); + + return isFilled; + }) + .orElseGet( + () -> { + log.warn("⚠️ 특약 {}번을 문서에서 찾을 수 없음!", order); + return false; + }); + } + + private Long getNextRoundNumber(ContractChat.ContractStatus status) { + switch (status) { + case ROUND0: + return 2L; + case ROUND1: + return 3L; + case ROUND2: + return 4L; + case ROUND3: + return null; + default: + return null; + } + } + + private String getRoundIncrementMessage(ContractChat.ContractStatus status) { + switch (status) { + case ROUND1: + return "1차 수정이 완료되었습니다! 2차 협상 라운드가 시작됩니다."; + case ROUND2: + return "2차 수정이 완료되었습니다! 3차 협상 라운드가 시작됩니다."; + case ROUND3: + return "3차 수정이 완료되었습니다! 최종 협상 라운드가 시작됩니다."; + default: + return "새로운 협상 라운드가 시작됩니다."; + } + } + + @Override + @Transactional + public ModificationRequestData requestFinalContractModification( + Long contractChatId, Long ownerId, FinalContractModificationRequestDto requestDto) { + + log.info("=== 최종 특약서 수정 요청 시작 ==="); + log.info( + "contractChatId: {}, ownerId: {}, clauseOrder: {}", + contractChatId, + ownerId, + requestDto.getClauseOrder()); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 수정 요청할 수 있습니다."); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + FinalSpecialContractDocument finalContract = finalContractOpt.get(); + boolean clauseExists = + finalContract.getFinalClauses().stream() + .anyMatch(clause -> clause.getOrder().equals(requestDto.getClauseOrder())); + + if (!clauseExists) { + throw new IllegalArgumentException( + "해당 특약 조항을 찾을 수 없습니다: " + requestDto.getClauseOrder()); + } + + String redisKey = + "final-contract:modification:" + contractChatId + ":" + requestDto.getClauseOrder(); + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); + } + + ModificationRequestData requestData = + ModificationRequestData.builder() + .contractChatId(contractChatId) + .clauseOrder(requestDto.getClauseOrder()) + .newTitle(requestDto.getNewTitle()) + .newContent(requestDto.getNewContent()) + .requesterId(ownerId) + .createdAt(LocalDateTime.now().toString()) + .build(); + + try { + String jsonData = objectMapper.writeValueAsString(requestData); + stringRedisTemplate.opsForValue().set(redisKey, jsonData, Duration.ofHours(24)); + + String notificationMessage = + String.format( + "임대인이 특약 %d번 수정을 요청했습니다.\n\n" + "📝 수정 제목: %s\n" + "✏️ 수정 내용: %s\n\n", + requestDto.getClauseOrder(), + requestDto.getNewTitle(), + requestDto.getNewContent()); + + AiMessageBtn(contractChatId, notificationMessage); + log.info("수정 요청 Redis 저장 완료 - key: {}", redisKey); + return requestData; + + } catch (Exception e) { + log.error("수정 요청 저장 실패", e); + throw new RuntimeException("수정 요청 저장 중 오류가 발생했습니다."); + } + } + + @Override + @Transactional + public FinalSpecialContractDocument respondToModificationRequest( + Long contractChatId, Long buyerId, FinalContractModificationResponseDto responseDto) { + + log.info("=== 수정 요청 응답 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, accepted: {}", + contractChatId, + buyerId, + responseDto.isAccepted()); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = + "final-contract:modification:" + + contractChatId + + ":" + + responseDto.getClauseOrder(); + String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (requestDataJson == null) { + throw new IllegalArgumentException("해당 조항에 대한 대기중인 수정 요청이 없습니다."); + } + + try { + ModificationRequestData requestData = + objectMapper.readValue(requestDataJson, ModificationRequestData.class); + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + String resultMessage; + + if (responseDto.isAccepted()) { + List updatedClauses = + finalContract.getFinalClauses().stream() + .map( + clause -> { + if (clause.getOrder() + .equals(responseDto.getClauseOrder())) { + return FinalSpecialContractDocument.FinalClause + .builder() + .order(clause.getOrder()) + .title(requestData.getNewTitle()) + .content(requestData.getNewContent()) + .build(); + } + return clause; + }) + .collect(Collectors.toList()); + + finalContract.setFinalClauses(updatedClauses); + + specialContractMongoRepository.saveFinalSpecialContract(finalContract); + + resultMessage = + String.format( + "임차인이 특약 %d번 수정 요청을 수락했습니다. 특약이 변경되었습니다.", + responseDto.getClauseOrder()); + + log.info("수정 수락 - 최종 특약서 업데이트 완료"); + + } else { + resultMessage = + String.format( + "임차인이 특약 %d번 수정 요청을 거절했습니다. 기존 특약이 유지됩니다.", + responseDto.getClauseOrder()); + + log.info("수정 거절 - 기존 특약서 유지"); + } + + stringRedisTemplate.delete(redisKey); + + AiMessage(contractChatId, resultMessage); + return finalContract; + + } catch (Exception e) { + log.error("수정 요청 응답 처리 실패", e); + throw new RuntimeException("응답 처리 중 오류가 발생했습니다."); + } + } + + @Override + public ModificationRequestData getPendingModificationRequest( + Long contractChatId, Integer clauseOrder) { + String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; + String requestDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (requestDataJson == null) { + return null; + } + + try { + return objectMapper.readValue(requestDataJson, ModificationRequestData.class); + } catch (Exception e) { + log.error("수정 요청 데이터 파싱 실패", e); + return null; + } + } + + @Override + public boolean hasPendingModificationRequest(Long contractChatId, Integer clauseOrder) { + String redisKey = "final-contract:modification:" + contractChatId + ":" + clauseOrder; + return stringRedisTemplate.hasKey(redisKey); + } + + @Override + @Transactional + public void requestFinalContractConfirmation(Long contractChatId, Long ownerId) { + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + AiMessage(contractChatId, "특약을 수락하였습니다"); + + String key = "final-contract:confirmation:" + contractChatId; + String existingValue = stringRedisTemplate.opsForValue().get(key); + if (existingValue != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다."); + } + String value = ownerId.toString(); + stringRedisTemplate.opsForValue().set(key, value); + } + + @Override + @Transactional + public Map acceptFinalContractConfirmation(Long contractChatId, Long buyerId) { + if (!isUserInContractChat(contractChatId, buyerId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + Long ownerId = contractChat.getOwnerId(); + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다."); + } + + String redisKey = "final-contract:confirmation:" + contractChatId; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다."); + } + + if (!storedOwnerId.equals(ownerId.toString())) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다."); + } + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP4); + + stringRedisTemplate.delete(redisKey); + + String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; + + AiMessage(contractChatId, confirmationMessage); + + return Map.of( + "message", + "최종 특약서가 확정되었습니다.", + "status", + "COMPLETED", + "finalContractId", + finalContract.getId(), + "totalFinalClauses", + finalContract.getTotalFinalClauses()); + } + + @Override + public void rejectFinalContractConfirmation(Long contractChatId, Long buyerId) { + String redisKey = "final-contract:confirmation:" + contractChatId; + stringRedisTemplate.delete(redisKey); + + AiMessage(contractChatId, "임차인이 수정을 거절하였습니다."); + } + + @Override + @Transactional + public void requestFinalContractDeletion( + Long contractChatId, Long ownerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 요청 시작 ==="); + log.info( + "contractChatId: {}, ownerId: {}, clauseOrder: {}", + contractChatId, + ownerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!ownerId.equals(contractChat.getOwnerId())) { + throw new BusinessException( + ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임대인만 삭제 요청할 수 있습니다."); + } + + Optional finalContractOpt = + specialContractMongoRepository.findFinalContractByContractChatId(contractChatId); + + if (finalContractOpt.isEmpty()) { + throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다."); + } + + FinalSpecialContractDocument finalContract = finalContractOpt.get(); + boolean clauseExists = + finalContract.getFinalClauses().stream() + .anyMatch(clause -> clause.getOrder().equals(clauseOrder)); + + if (!clauseExists) { + throw new IllegalArgumentException("해당 특약 조항을 찾을 수 없습니다: " + clauseOrder); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 삭제 요청이 진행 중입니다."); + } + + stringRedisTemplate.opsForValue().set(redisKey, ownerId.toString(), Duration.ofHours(24)); + + String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); + + AiMessage(contractChatId, notificationMessage); + + log.info("삭제 요청 Redis 저장 완료 - key: {}, value: {}", redisKey, ownerId); + } + + @Override + @Transactional + public Map acceptFinalContractDeletion( + Long contractChatId, Long buyerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 수락 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, clauseOrder: {}", + contractChatId, + buyerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); + } + + if (!storedOwnerId.equals(contractChat.getOwnerId().toString())) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "삭제 요청 정보가 유효하지 않습니다."); + } + + FinalSpecialContractDocument finalContract = + specialContractMongoRepository + .findFinalContractByContractChatId(contractChatId) + .orElseThrow(() -> new IllegalArgumentException("최종 특약서를 찾을 수 없습니다.")); + + List updatedClauses = + finalContract.getFinalClauses().stream() + .filter(clause -> !clause.getOrder().equals(clauseOrder)) + .collect(Collectors.toList()); + + finalContract.setFinalClauses(updatedClauses); + finalContract.setTotalFinalClauses(updatedClauses.size()); + + specialContractMongoRepository.saveFinalSpecialContract(finalContract); + + stringRedisTemplate.delete(redisKey); + + String confirmationMessage = + String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); + + AiMessage(contractChatId, confirmationMessage); + + log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); + + return Map.of( + "message", + "특약이 삭제되었습니다.", + "deletedClauseOrder", + clauseOrder, + "finalContractId", + finalContract.getId(), + "remainingClauses", + finalContract.getTotalFinalClauses()); + } + + @Override + @Transactional + public void rejectFinalContractDeletion( + Long contractChatId, Long buyerId, Integer clauseOrder) { + log.info("=== 최종 특약 삭제 거절 처리 시작 ==="); + log.info( + "contractChatId: {}, buyerId: {}, clauseOrder: {}", + contractChatId, + buyerId, + clauseOrder); + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } + + if (!buyerId.equals(contractChat.getBuyerId())) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 응답할 수 있습니다."); + } + + String redisKey = "final-contract:deletion:" + contractChatId + ":" + clauseOrder; + String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey); + + if (storedOwnerId == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); + } + + stringRedisTemplate.delete(redisKey); + + String rejectionMessage = + String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); + + AiMessage(contractChatId, rejectionMessage); + + log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); + } +} diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index 3eafbf88..e1d76106 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -27,11 +27,7 @@ public interface HomeController { @ApiOperation(value = "매물 등록", notes = "새로운 매물을 등록합니다. 이미지 파일을 함께 업로드할 수 있습니다.") @PostMapping(consumes = "multipart/form-data") ResponseEntity> createHome( - @Valid @ModelAttribute HomeCreateRequestDto requestDto, - @ApiParam(value = "매물 이미지 파일들 (최대 5개)") - @RequestParam(value = "images", required = false) - List images, - Authentication authentication); + @Valid @ModelAttribute HomeCreateRequestDto requestDto, Authentication authentication); @ApiOperation(value = "매물 상세 조회", notes = "매물 ID로 상세 정보를 조회합니다.") @GetMapping("/{homeId}") diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index 7d612027..05e33b0e 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -1,6 +1,5 @@ package org.scoula.domain.home.controller; -import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -37,23 +36,14 @@ public class HomeControllerImpl implements HomeController { private final UserServiceInterface userService; @Override - @PostMapping + @PostMapping(consumes = "multipart/form-data") public ResponseEntity> createHome( - @Valid @ModelAttribute HomeCreateRequestDto requestDto, - @RequestParam(value = "images", required = false) List images, - Authentication authentication) { + @Valid @RequestPart HomeCreateRequestDto requestDto, Authentication authentication) { Integer userId = getCurrentUserId(authentication); // 이미지 파일 리스트 처리 - List imageList = new ArrayList<>(); - if (images != null && !images.isEmpty()) { - for (MultipartFile image : images) { - if (image != null && !image.isEmpty()) { - imageList.add(image); - } - } - } + List imageList = requestDto.getImages(); log.info( "매물 등록 요청: userId={}, residenceType={}, 이미지 개수={}", @@ -78,6 +68,8 @@ public ResponseEntity> createHome( .homeFloor(requestDto.getHomeFloor()) .buildingTotalFloors(requestDto.getBuildingTotalFloors()) .homeDirection(requestDto.getHomeDirection()) + .area(requestDto.getArea()) + .landCategory(requestDto.getLandCategory()) .bathroomCnt(requestDto.getBathroomCnt()) .isPet(requestDto.getIsPet()) .isParking(requestDto.getIsParking()) diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java index 1c6d0053..074f1096 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java @@ -8,6 +8,7 @@ import org.scoula.domain.home.enums.HomeDirection; import org.scoula.domain.home.enums.LeaseType; import org.scoula.domain.home.enums.ResidenceType; +import org.springframework.web.multipart.MultipartFile; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; @@ -94,6 +95,9 @@ public class HomeCreateRequestDto { @ApiModelProperty(value = "관리비 항목 목록") private List maintenanceFees; + @ApiModelProperty(value = "매물 이미지 목록") + private List images; + @ApiModel(description = "관리비 항목 DTO") @Data @NoArgsConstructor diff --git a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java index 28b96715..d473c483 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java @@ -24,5 +24,4 @@ public class HomeDetailVO { private Boolean isParking; private Float area; private String landCategory; - } diff --git a/src/main/java/org/scoula/domain/home/vo/HomeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeVO.java index 415f6ea8..642080a6 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeVO.java @@ -36,5 +36,4 @@ public class HomeVO { private Float exclusiveArea; private Float area; private String landCategory; - } diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index f3b600ed..58cfeaa0 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -22,7 +22,7 @@ INSERT INTO home_detail ( home_id, build_date, home_floor, building_total_floors, - home_direction, bathroom_count, is_pet, is_parking_available,area,landCategory + home_direction, bathroom_count, is_pet, is_parking_available,area,land_category ) VALUES ( #{homeId}, #{buildDate}, #{homeFloor}, #{buildingTotalFloors}, From d406ae9bd2eaf6f7054a1ff8f258a732d9dff479 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Tue, 12 Aug 2025 15:45:18 +0900 Subject: [PATCH 22/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/mapper/HomeMapper.xml | 145 +++++------------- 1 file changed, 39 insertions(+), 106 deletions(-) diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index 58cfeaa0..2b4ac6a7 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -3,7 +3,6 @@ - INSERT INTO home ( user_id, addr1, addr2, residence_type, lease_type, @@ -18,7 +17,6 @@ ) - INSERT INTO home_detail ( home_id, build_date, home_floor, building_total_floors, @@ -30,31 +28,26 @@ ) - INSERT INTO home_image (home_id, image_url) VALUES (#{homeId}, #{imageUrl}) - INSERT INTO home_facility (home_detail_id, item_id) VALUES (#{homeDetailId}, #{itemId}) - INSERT INTO home_maintenance_fee (home_id, maintenance_id, fee) VALUES (#{homeId}, #{maintenanceId}, #{fee}) - INSERT INTO home_like (user_id, home_id, liked_at) VALUES (#{userId}, #{homeId}, #{likedAt}) - - - - + - - + SELECT fi.item_id as itemId, fi.category_id as categoryId, fi.item_name as itemName + FROM home_facility hf + JOIN facility_item fi ON hf.item_id = fi.item_id + WHERE hf.home_detail_id = #{homeDetailId} - - + SELECT hmf.home_id as homeId, hmf.maintenance_id as maintenanceId, hmf.fee, + mi.item_name as itemName + FROM home_maintenance_fee hmf + JOIN maintenance_item mi ON hmf.maintenance_id = mi.maintenance_id + WHERE hmf.home_id = #{homeId} + + + + + - - DELETE FROM home_like WHERE user_id = #{userId} AND home_id = #{homeId} - - - UPDATE home SET addr1 = #{addr1}, @@ -262,7 +231,6 @@ WHERE home_id = #{homeId} - UPDATE home_detail SET build_date = #{buildDate}, @@ -275,70 +243,35 @@ WHERE home_detail_id = #{homeDetailId} - UPDATE home SET home_status = #{status} WHERE home_id = #{homeId} - DELETE FROM home WHERE home_id = #{homeId} - DELETE FROM home_image WHERE home_id = #{homeId} - DELETE FROM home_maintenance_fee WHERE home_id = #{homeId} - DELETE FROM home_facility WHERE home_detail_id = #{homeDetailId} - - - - - - - - - - - - - - + SELECT COUNT(*) FROM home WHERE home_status = 'AVAILABLE' + + UPDATE home + SET view_cnt = view_cnt + 1 + WHERE home_id = #{homeId} + \ No newline at end of file From ffbcc78f454ff2cca2c4070e26a3b2d40591e9e9 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Tue, 12 Aug 2025 17:09:05 +0900 Subject: [PATCH 23/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-submodule | 2 +- .../domain/home/service/HomeServiceImpl.java | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/config-submodule b/config-submodule index 2d91d5b9..04f53b7d 160000 --- a/config-submodule +++ b/config-submodule @@ -1 +1 @@ -Subproject commit 2d91d5b967d71c3c6a3a658d296a1453141a3c24 +Subproject commit 04f53b7d1a2b9d22c3abaa07759c5a98fe287200 diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index 02b3962a..fd7858b8 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -390,20 +390,34 @@ public List getFacilityItemsByCategory(Integer categoryId) { @Override public void toggleHomeLike(Integer userId, Integer homeId) { + log.info("찜 토글 요청: userId={}, homeId={}", userId, homeId); + + // 찜 상태 확인 int exists = homeMapper.selectHomeLikeExists(userId, homeId); + log.info("찜 상태 확인 결과: exists={}", exists); if (exists > 0) { - homeMapper.deleteHomeLike(userId, homeId); - log.info("찜 해제: userId={}, homeId={}", userId, homeId); + // 찜 상태인 경우, 찜 삭제 + int deleteCount = homeMapper.deleteHomeLike(userId, homeId); + if (deleteCount > 0) { + log.info("찜 해제 성공: userId={}, homeId={}", userId, homeId); + } else { + log.warn("찜 해제 실패: userId={}, homeId={}", userId, homeId); + } } else { + // 찜 상태가 아닌 경우, 찜 등록 HomeLikeVO homeLike = HomeLikeVO.builder() .userId(userId) .homeId(homeId) .likedAt(LocalDate.now()) .build(); - homeMapper.insertHomeLike(homeLike); - log.info("찜 등록: userId={}, homeId={}", userId, homeId); + int insertCount = homeMapper.insertHomeLike(homeLike); + if (insertCount > 0) { + log.info("찜 등록 성공: userId={}, homeId={}", userId, homeId); + } else { + log.warn("찜 등록 실패: userId={}, homeId={}", userId, homeId); + } } } From cf04320c162f5c22beaf938b5ad5ab84e9df6979 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Wed, 13 Aug 2025 12:46:05 +0900 Subject: [PATCH 24/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20home=5Ffloor?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/scoula/domain/home/service/HomeServiceImpl.java | 2 ++ src/main/java/org/scoula/domain/home/vo/HomeVO.java | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index fd7858b8..5e047a6f 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -297,6 +297,8 @@ public List getHomeList(int page, int size) { .likeCnt(home.getLikeCnt()) .roomCnt(home.getRoomCnt()) .supplyArea(home.getSupplyArea()) + .exclusiveArea(home.getExclusiveArea()) + .homeFloor(home.getHomeFloor()) .imageUrls( mainImageUrl != null ? List.of(mainImageUrl) diff --git a/src/main/java/org/scoula/domain/home/vo/HomeVO.java b/src/main/java/org/scoula/domain/home/vo/HomeVO.java index 642080a6..89d2c7d8 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeVO.java @@ -36,4 +36,5 @@ public class HomeVO { private Float exclusiveArea; private Float area; private String landCategory; + private Integer homeFloor; } From 7361f17a6372e87bda4ba5d3c0fda320a6e632bd Mon Sep 17 00:00:00 2001 From: seonju21 Date: Wed, 13 Aug 2025 15:55:11 +0900 Subject: [PATCH 25/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20home=5Ffloor?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/controller/HomeControllerImpl.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index 05e33b0e..f96c41dc 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -45,12 +45,6 @@ public ResponseEntity> createHome( // 이미지 파일 리스트 처리 List imageList = requestDto.getImages(); - log.info( - "매물 등록 요청: userId={}, residenceType={}, 이미지 개수={}", - userId, - requestDto.getResidenceType(), - imageList.size()); - // HomeCreateRequestDto를 HomeCreateDTO로 변환 HomeCreateDTO createDTO = HomeCreateDTO.builder() From a5add298b668c67972aa408ed0ccc7dd81b1a4c2 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Thu, 14 Aug 2025 15:27:42 +0900 Subject: [PATCH 26/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/controller/HomeController.java | 3 ++- .../domain/home/controller/HomeControllerImpl.java | 10 ++++++---- .../org/scoula/domain/home/mapper/HomeMapper.xml | 6 ++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index e1d76106..66943f28 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -11,6 +11,7 @@ import org.scoula.domain.home.vo.FacilityCategory; import org.scoula.domain.home.vo.FacilityItem; import org.scoula.global.common.dto.ApiResponse; +import org.scoula.global.common.dto.PageResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -36,7 +37,7 @@ ResponseEntity> getHome( @ApiOperation(value = "매물 목록 조회", notes = "페이징된 매물 목록을 조회합니다.") @GetMapping - ResponseEntity>> getHomeList( + ResponseEntity> getHomeList( @ApiParam(value = "페이지 번호 (1부터 시작)", defaultValue = "1") @RequestParam(defaultValue = "1") int page, diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index f96c41dc..b3f18dde 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -16,6 +16,8 @@ import org.scoula.domain.user.service.UserServiceInterface; import org.scoula.domain.user.vo.User; import org.scoula.global.common.dto.ApiResponse; +import org.scoula.global.common.dto.PageRequest; +import org.scoula.global.common.dto.PageResponse; import org.scoula.global.common.exception.BusinessException; import org.scoula.global.common.exception.CommonErrorCode; import org.springframework.http.ResponseEntity; @@ -102,7 +104,7 @@ public ResponseEntity> getHome(@PathVariable Intege @Override @GetMapping - public ResponseEntity>> getHomeList( + public ResponseEntity> getHomeList( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { @@ -111,9 +113,9 @@ public ResponseEntity>> getHomeList( List homes = homeService.getHomeList(page, size); int totalCount = homeService.getTotalHomeCount(); - return ResponseEntity.ok( - ApiResponse.success( - homes, String.format("매물 목록 조회가 완료되었습니다. (총 %d개)", totalCount))); + PageRequest pageRequest = PageRequest.builder().page(page).size(size).build(); + + return ResponseEntity.ok(PageResponse.of(homes, pageRequest, totalCount)); } @Override diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index 2b4ac6a7..c8707695 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -7,13 +7,11 @@ INSERT INTO home ( user_id, addr1, addr2, residence_type, lease_type, deposit_price, monthly_rent, maintenance_fee, home_status, - view_cnt, like_cnt, chat_cnt, room_cnt, supply_area, exclusive_area, - created_at, updated_at + view_cnt, like_cnt, chat_cnt, room_cnt, supply_area, exclusive_area ) VALUES ( #{userId}, #{addr1}, #{addr2}, #{residenceType}, #{leaseType}, #{depositPrice}, #{monthlyRent}, #{maintenaceFee}, #{homeStatus}, - #{viewCnt}, #{likeCnt}, #{chatCnt}, #{roomCnt}, #{supplyArea}, #{exclusiveArea}, - #{createdAt}, #{updatedAt} + #{viewCnt}, #{likeCnt}, #{chatCnt}, #{roomCnt}, #{supplyArea}, #{exclusiveArea} ) From 0d6849f19fceccc9e82c0a287b81f23e9bd6eaa0 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Thu, 14 Aug 2025 15:39:39 +0900 Subject: [PATCH 27/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/scoula/domain/home/controller/HomeController.java | 2 +- .../org/scoula/domain/home/controller/HomeControllerImpl.java | 2 +- src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index 66943f28..c3ae9a05 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -41,7 +41,7 @@ ResponseEntity> getHomeList( @ApiParam(value = "페이지 번호 (1부터 시작)", defaultValue = "1") @RequestParam(defaultValue = "1") int page, - @ApiParam(value = "페이지 크기", defaultValue = "20") @RequestParam(defaultValue = "20") + @ApiParam(value = "페이지 크기", defaultValue = "21") @RequestParam(defaultValue = "21") int size); @ApiOperation(value = "매물 검색", notes = "조건에 따라 매물을 검색합니다.") diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index b3f18dde..87d5456d 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -106,7 +106,7 @@ public ResponseEntity> getHome(@PathVariable Intege @GetMapping public ResponseEntity> getHomeList( @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int size) { + @RequestParam(defaultValue = "21") int size) { log.info("매물 목록 조회 요청: page={}, size={}", page, size); diff --git a/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java index 5eb894ab..4ec0893d 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java @@ -43,7 +43,7 @@ public class HomeSearchDTO { // 페이징 private Integer page = 1; - private Integer size = 20; + private Integer size = 21; // 정렬 private String sortBy = "createdAt"; // createdAt, price, viewCnt, likeCnt From 4bc42d9c53f437dd19ecf6440aa6447b73a700c9 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Thu, 14 Aug 2025 17:59:43 +0900 Subject: [PATCH 28/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeController.java | 2 +- .../home/controller/HomeControllerImpl.java | 7 +- .../domain/home/enums/HomeDirection.java | 4 + .../scoula/domain/home/enums/HomeStatus.java | 4 + .../scoula/domain/home/mapper/HomeMapper.xml | 175 +++++++++++++----- 5 files changed, 145 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeController.java b/src/main/java/org/scoula/domain/home/controller/HomeController.java index c3ae9a05..e29cb24e 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeController.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeController.java @@ -46,7 +46,7 @@ ResponseEntity> getHomeList( @ApiOperation(value = "매물 검색", notes = "조건에 따라 매물을 검색합니다.") @GetMapping("/search") - ResponseEntity>> searchHomes( + ResponseEntity> searchHomes( @ApiParam(value = "검색 조건") @ModelAttribute HomeSearchDTO searchDTO); @ApiOperation( diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index 87d5456d..028eb0f8 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -120,7 +120,7 @@ public ResponseEntity> getHomeList( @Override @GetMapping("/search") - public ResponseEntity>> searchHomes( + public ResponseEntity> searchHomes( @ModelAttribute HomeSearchDTO searchDTO) { log.info( @@ -131,9 +131,10 @@ public ResponseEntity>> searchHomes( List homes = homeService.searchHomes(searchDTO); int totalCount = homeService.getHomeCountByCondition(searchDTO); + PageRequest pageRequest = + PageRequest.builder().page(searchDTO.getPage()).size(searchDTO.getSize()).build(); - return ResponseEntity.ok( - ApiResponse.success(homes, String.format("매물 검색이 완료되었습니다. (총 %d개)", totalCount))); + return ResponseEntity.ok(PageResponse.of(homes, pageRequest, totalCount)); } @Override diff --git a/src/main/java/org/scoula/domain/home/enums/HomeDirection.java b/src/main/java/org/scoula/domain/home/enums/HomeDirection.java index 9e1a36ef..3574c81b 100644 --- a/src/main/java/org/scoula/domain/home/enums/HomeDirection.java +++ b/src/main/java/org/scoula/domain/home/enums/HomeDirection.java @@ -24,6 +24,10 @@ public String getDescription() { return description; } + public String getKey() { + return this.name(); + } + @JsonCreator public static HomeDirection from(String value) { for (HomeDirection direction : HomeDirection.values()) { diff --git a/src/main/java/org/scoula/domain/home/enums/HomeStatus.java b/src/main/java/org/scoula/domain/home/enums/HomeStatus.java index 81c96639..9721ef55 100644 --- a/src/main/java/org/scoula/domain/home/enums/HomeStatus.java +++ b/src/main/java/org/scoula/domain/home/enums/HomeStatus.java @@ -19,6 +19,10 @@ public String getDescription() { return description; } + public String getKey() { + return this.name(); + } + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static HomeStatus from(String value) { if (value == null || value.trim().isEmpty()) { diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index c8707695..c1300ed3 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -93,50 +93,85 @@ h.maintenance_fee as maintenaceFee, h.home_status as homeStatus, h.view_cnt as viewCnt, h.like_cnt as likeCnt, h.chat_cnt as chatCnt, h.room_cnt as roomCnt, h.supply_area as supplyArea, h.exclusive_area as exclusiveArea, - h.created_at as createdAt, h.updated_at as updatedAt + h.created_at as createdAt, h.updated_at as updatedAt, + hd.home_floor as homeFloor, hd.building_total_floors as buildingTotalFloors FROM home h - WHERE 1=1 - - AND h.residence_type = #{search.residenceType} - - - AND h.lease_type = #{search.leaseType} - - - AND h.home_status = #{search.homeStatus} - - - AND h.deposit_price >= #{search.minDepositPrice} - - - AND h.deposit_price <= #{search.maxDepositPrice} - - - AND h.monthly_rent >= #{search.minMonthlyRent} - - - AND h.monthly_rent <= #{search.maxMonthlyRent} - - - AND h.maintenance_fee <= #{search.maxMaintenanceFee} - - - AND h.supply_area >= #{search.minSupplyArea} - - - AND h.supply_area <= #{search.maxSupplyArea} - - - AND h.room_cnt >= #{search.minRoomCnt} - - - AND h.room_cnt <= #{search.maxRoomCnt} - - - AND h.addr1 LIKE CONCAT('%', #{search.addr1}, '%') - - ORDER BY h.created_at DESC - LIMIT #{search.limit} OFFSET #{search.offset} + LEFT JOIN home_detail hd ON h.home_id = hd.home_id + + h.home_status = 'AVAILABLE' + + AND h.residence_type = #{search.residenceType} + + + AND h.lease_type = #{search.leaseType} + + + AND h.addr1 LIKE CONCAT('%', #{search.addr1}, '%') + + + AND h.deposit_price <= #{search.maxDepositPrice} + + + AND h.monthly_rent <= #{search.maxMonthlyRent} + + + AND h.maintenance_fee <= #{search.maxMaintenanceFee} + + + AND h.supply_area >= #{search.minSupplyArea} + + + AND h.supply_area <= #{search.maxSupplyArea} + + + AND h.room_cnt >= #{search.minRoomCnt} + + + AND h.room_cnt <= #{search.maxRoomCnt} + + + AND hd.home_direction = #{search.homeDirection.getKey()} + + + AND hd.home_floor >= #{search.minFloor} + + + AND hd.home_floor <= #{search.maxFloor} + + + AND hd.is_pet = #{search.isPet} + + + AND hd.is_parking_available = #{search.isParking} + + + + + ORDER BY + + h.deposit_price, h.monthly_rent + + + h.view_cnt + + + h.like_cnt + + + h.created_at + + + DESC + + + ASC + + + + ORDER BY h.created_at DESC + + + LIMIT #{search.size} OFFSET #{search.offset} + + UPDATE home SET view_cnt = view_cnt + 1 From df28b509f7697baa8a44cdb334c72f25c28daf9a Mon Sep 17 00:00:00 2001 From: seonju21 Date: Thu, 14 Aug 2025 18:53:05 +0900 Subject: [PATCH 29/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java | 2 ++ .../org/scoula/domain/home/service/HomeServiceImpl.java | 2 ++ .../resources/org/scoula/domain/home/mapper/HomeMapper.xml | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java index 4ec0893d..e580e3f7 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeSearchDTO.java @@ -31,6 +31,8 @@ public class HomeSearchDTO { private Float maxSupplyArea; private Integer minRoomCnt; private Integer maxRoomCnt; + private Float exclusiveArea; + private Integer homeFloor; // 층수 범위 private Integer minFloor; diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index 5e047a6f..57342ffe 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -257,6 +257,8 @@ public List searchHomes(HomeSearchDTO searchDTO) { .monthlyRent(home.getMonthlyRent()) .maintenaceFee(home.getMaintenaceFee()) .homeStatus(home.getHomeStatus()) + .exclusiveArea(home.getExclusiveArea()) + .homeFloor(home.getHomeFloor()) .viewCnt(home.getViewCnt()) .likeCnt(home.getLikeCnt()) .roomCnt(home.getRoomCnt()) diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index c1300ed3..87308329 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -129,9 +129,15 @@ AND h.room_cnt <= #{search.maxRoomCnt} + + AND h.exclusive_area= #{search.exclusiveArea} + AND hd.home_direction = #{search.homeDirection.getKey()} + + AND hd.home_floor = #{search.homeFloor} + AND hd.home_floor >= #{search.minFloor} From 237672e9577f2e77c49d7b2191997dc78842c80b Mon Sep 17 00:00:00 2001 From: seonju21 Date: Sat, 16 Aug 2025 14:29:53 +0900 Subject: [PATCH 30/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/home/controller/HomeControllerImpl.java | 1 + .../resources/org/scoula/domain/home/mapper/HomeMapper.xml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index 028eb0f8..c28bce16 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -128,6 +128,7 @@ public ResponseEntity> searchHomes( searchDTO.getResidenceType(), searchDTO.getLeaseType(), searchDTO.getAddr1()); + log.info("최대 월세 필터 값: {}", searchDTO.getMaxMonthlyRent()); List homes = homeService.searchHomes(searchDTO); int totalCount = homeService.getHomeCountByCondition(searchDTO); diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index 87308329..fa404097 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -133,7 +133,7 @@ AND h.exclusive_area= #{search.exclusiveArea} - AND hd.home_direction = #{search.homeDirection.getKey()} + AND hd.home_direction = #{search.homeDirection} AND hd.home_floor = #{search.homeFloor} @@ -347,6 +347,9 @@ AND hd.home_direction = #{search.homeDirection} + + AND hd.home_floor = #{search.homeFloor} + AND hd.home_floor >= #{search.minFloor} From 6b2e7c953fc8456af985d2e26bad686e5aeb8a61 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Sun, 17 Aug 2025 11:36:26 +0900 Subject: [PATCH 31/34] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Mapper=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/scoula/domain/home/mapper/HomeMapper.java | 2 ++ .../resources/org/scoula/domain/home/mapper/HomeMapper.xml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java b/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java index dc47787c..58acc029 100644 --- a/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java +++ b/src/main/java/org/scoula/domain/home/mapper/HomeMapper.java @@ -10,6 +10,8 @@ @Mapper public interface HomeMapper { + HomeVO findHomeById(@Param("id") Long id); + // === 기본 매물 관리 === // 매물 등록 int insertHome(HomeVO home); diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index f67f73f2..50d23cee 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -287,6 +287,9 @@ WHERE h.user_id = #{userId} ORDER BY h.created_at DESC + From 1478e0782774f740fbe48a3c9cf5102803d7e2b0 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Sun, 17 Aug 2025 11:37:52 +0900 Subject: [PATCH 32/34] =?UTF-8?q?=E2=9C=A8=20feat:=20Home=20dto=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/HomeCreateRequestDto.java | 64 -------- .../dto/request/HomeReportRequestDto.java | 77 --------- .../dto/response/FacilityResponseDto.java | 18 -- .../home/dto/response/HomeResponseDto.java | 120 -------------- .../MaintenanceFeeItemResponseDto.java | 15 -- .../scoula/domain/home/vo/HomeRegisterVO.java | 154 ------------------ 6 files changed, 448 deletions(-) delete mode 100644 src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java delete mode 100644 src/main/java/org/scoula/domain/home/dto/request/HomeReportRequestDto.java delete mode 100644 src/main/java/org/scoula/domain/home/dto/response/FacilityResponseDto.java delete mode 100644 src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java delete mode 100644 src/main/java/org/scoula/domain/home/dto/response/MaintenanceFeeItemResponseDto.java delete mode 100644 src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java diff --git a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java deleted file mode 100644 index b55cedeb..00000000 --- a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.scoula.domain.home.dto.request; - -import java.time.LocalDate; -import java.util.List; - -import org.scoula.domain.home.enums.LeaseType; -import org.scoula.domain.home.enums.ResidenceType; -import org.scoula.domain.home.vo.HomeRegisterVO; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.multipart.MultipartFile; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class HomeCreateRequestDto { - - private String addr1; // 시/도 + 구/군 - private String addr2; // 상세 주소 - private String userName; // 유저 실명 - - private ResidenceType residenceType; // 예: 오피스텔, 투룸 - private LeaseType leaseType; // 전세, 월세 - - private Integer depositPrice; // 보증금 - private Integer monthlyRent; // 월세 - private Integer maintenanceFee; // 관리비 - private String itemName; - - private Float supplyArea; // 공급면적 - private Float exclusiveArea; // 전용면적 - - private String homeFloor; // 층 정보 (예: "5층 / 15층" 등) - - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - private LocalDate buildDate; - - private Integer buildingTotalFloors; - private Boolean isPet; - private Boolean isParkingAvailable; - - private Integer roomCnt; // 방 개수 - private Integer bathroomCount; // 욕실 개수 - private String homeDirection; // 남향, 북향 등 - - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - private LocalDate moveInDate; - - private List options; // 옵션 (가전 등) - private List facilityItemIds; // 시설 ID 리스트 - - // ✅ 관리비 항목 리스트 - private List maintenanceFeeItems; - - // ✅ 이미지 관련: 업로드용 파일과 저장용 URL 분리 - private List images; - private List imageFiles; - private List imageUrls; // DB 저장용 -} diff --git a/src/main/java/org/scoula/domain/home/dto/request/HomeReportRequestDto.java b/src/main/java/org/scoula/domain/home/dto/request/HomeReportRequestDto.java deleted file mode 100644 index 79542acb..00000000 --- a/src/main/java/org/scoula/domain/home/dto/request/HomeReportRequestDto.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.scoula.domain.home.dto.request; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; - -import org.scoula.domain.home.vo.HomeReportVO; - -import io.swagger.annotations.ApiModel; -import io.swagger.annotations.ApiModelProperty; -import lombok.*; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@ApiModel(description = "매물 신고 요청 및 응답 DTO") -public class HomeReportRequestDto { - - @ApiModelProperty(value = "신고 ID", example = "1", readOnly = true) - private Long reportId; - - @ApiModelProperty(value = "신고한 유저 ID", example = "3", readOnly = true) - private Long userId; - - @ApiModelProperty(value = "신고 대상 매물 ID", example = "3", required = true) - private Long homeId; - - @ApiModelProperty(value = "신고 사유", example = "사진과 실제 매물이 다릅니다.", required = true) - private String reportReason; - - @ApiModelProperty(value = "신고 일시", example = "2025-07-23T00:47:37", readOnly = true) - private LocalDateTime reportAt; - - @ApiModelProperty(value = "신고 처리 상태", example = "PROCESSING", readOnly = true) - private String reportStatus; - - private static final DateTimeFormatter DATE_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - // VO -> DTO - public static HomeReportRequestDto from(HomeReportVO vo) { - if (vo == null) { - return null; - } - return HomeReportRequestDto.builder() - .reportId(vo.getReportId()) - .userId(vo.getUserId()) - .homeId(vo.getHomeId()) - .reportReason(vo.getReportReason()) - .reportAt(vo.getReportAt()) - .reportStatus(vo.getReportStatus()) - .build(); - } - - // DTO -> VO - public HomeReportVO toVO() { - return new HomeReportVO( - this.reportId, - this.userId, - this.homeId, - this.reportReason, - this.reportAt, - this.reportStatus); - } - - // 날짜 문자열 -> LocalDateTime 변환 - private static LocalDateTime parseDateTime(String dateTime) { - try { - return LocalDateTime.parse( - dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - } catch (DateTimeParseException e) { - throw new IllegalArgumentException("Invalid date format: " + dateTime, e); - } - } -} diff --git a/src/main/java/org/scoula/domain/home/dto/response/FacilityResponseDto.java b/src/main/java/org/scoula/domain/home/dto/response/FacilityResponseDto.java deleted file mode 100644 index 49696518..00000000 --- a/src/main/java/org/scoula/domain/home/dto/response/FacilityResponseDto.java +++ /dev/null @@ -1,18 +0,0 @@ -// src/main/java/org/scoula/domain/home/dto/response/FacilityResponseDto.java -package org.scoula.domain.home.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class FacilityResponseDto { - private Long itemId; - private String itemName; - private Long categoryId; - private String categoryType; -} diff --git a/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java b/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java deleted file mode 100644 index 18b70842..00000000 --- a/src/main/java/org/scoula/domain/home/dto/response/HomeResponseDto.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.scoula.domain.home.dto.response; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Collections; -import java.util.List; - -import org.scoula.domain.home.enums.LeaseType; -import org.scoula.domain.home.enums.ResidenceType; -import org.scoula.domain.home.vo.HomeRegisterVO; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class HomeResponseDto { - private Long homeId; - private Long homeDetailId; - private Long userId; - private String userName; - - private String addr1; - private String addr2; - - private ResidenceType residenceType; - private LeaseType leaseType; - - private Integer depositPrice; - private Integer monthlyRent; - private Integer maintenanceFee; - - private Float supplyArea; - private Float exclusiveArea; - - private String homeFloor; - private Integer buildingTotalFloors; - - private Integer roomCnt; - private Integer bathroomCount; - private String homeDirection; - - private Boolean isPet; - private Boolean isParkingAvailable; - - private LocalDate buildDate; - private LocalDate moveInDate; - - private Integer viewCnt; - private Integer chatCnt; - private Integer likeCnt; - - private String imageUrl; - private Long imageId; - - private List options; - private List facilityItemIds; - - private String createdAt; - - private List maintenanceFeeItems; - private List facilities; // 시설 정보 필드 추가 - - // (기존) MaintenanceFeeItemResponseDto 클래스는 별도 파일로 분리 - // (기존) HomeRegisterVO$MaintenanceFeeItem - - // 세 가지 인자를 받는 from 메서드 추가 - public static HomeResponseDto from( - HomeRegisterVO vo, - List maintenanceItems, - List facilities) { - String createdAtStr = null; - if (vo.getCreatedAt() != null) { - createdAtStr = - vo.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - } - - return HomeResponseDto.builder() - .homeId(vo.getHomeId()) - .userId(vo.getUserId()) - .userName(vo.getUserName()) - .homeDetailId(vo.getHomeDetailId()) - .addr1(vo.getAddr1()) - .addr2(vo.getAddr2()) - .residenceType(vo.getResidenceType()) - .leaseType(vo.getLeaseType()) - .depositPrice(vo.getDepositPrice()) - .monthlyRent(vo.getMonthlyRent()) - .maintenanceFee(vo.getMaintenanceFee()) - .supplyArea(vo.getSupplyArea()) - .exclusiveArea(vo.getExclusiveArea()) - .homeFloor(vo.getHomeFloor() != null ? vo.getHomeFloor() : "") - .buildingTotalFloors(vo.getBuildingTotalFloors()) - .roomCnt(vo.getRoomCnt()) - .likeCnt(vo.getLikeCnt()) - .viewCnt(vo.getViewCnt()) - .chatCnt(vo.getChatCnt()) - .bathroomCount(vo.getBathroomCount()) - .homeDirection(vo.getHomeDirection() != null ? vo.getHomeDirection().name() : null) - .isPet(vo.getIsPet()) - .isParkingAvailable(vo.getIsParkingAvailable()) - .buildDate(vo.getBuildDate() != null ? vo.getBuildDate().toLocalDate() : null) - .moveInDate(vo.getMoveInDate()) - .imageUrl(vo.getImageUrl()) - .imageId(vo.getImageId()) - .options(vo.getOptions() != null ? vo.getOptions() : Collections.emptyList()) - .facilityItemIds( - vo.getFacilityItemIds() != null - ? vo.getFacilityItemIds() - : Collections.emptyList()) - .createdAt(createdAtStr) - .maintenanceFeeItems(maintenanceItems) - .facilities(facilities) // 시설 정보 필드 설정 - .build(); - } -} diff --git a/src/main/java/org/scoula/domain/home/dto/response/MaintenanceFeeItemResponseDto.java b/src/main/java/org/scoula/domain/home/dto/response/MaintenanceFeeItemResponseDto.java deleted file mode 100644 index fd39cef7..00000000 --- a/src/main/java/org/scoula/domain/home/dto/response/MaintenanceFeeItemResponseDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.scoula.domain.home.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class MaintenanceFeeItemResponseDto { - private String itemName; - private Integer fee; -} diff --git a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java b/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java deleted file mode 100644 index 1e33625e..00000000 --- a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.scoula.domain.home.vo; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -import org.scoula.domain.home.dto.request.HomeCreateRequestDto; -import org.scoula.domain.home.dto.request.HomeUpdateRequestDto; -import org.scoula.domain.home.enums.HomeDirection; -import org.scoula.domain.home.enums.HomeStatus; -import org.scoula.domain.home.enums.LeaseType; -import org.scoula.domain.home.enums.ResidenceType; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class HomeRegisterVO { - - // 매물 기본 정보 - private Long homeId; - private Long userId; - private String userName; - private String addr1; - private String addr2; - private ResidenceType residenceType; - private LeaseType leaseType; - private Integer depositPrice; - private Integer monthlyRent; - private Integer maintenanceFee; - private HomeStatus homeStatus; - private Integer viewCnt; - private Integer likeCnt; - private Integer chatCnt; - private Integer reportCnt; - private Integer roomCnt; - private Float supplyArea; - private Float exclusiveArea; - private String homeFloor; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - // 상세 정보 - private Long homeDetailId; - private LocalDateTime buildDate; - private Integer floor; - private Integer buildingTotalFloors; - private HomeDirection homeDirection; - private Integer bathroomCount; - private Boolean isPet; - private LocalDate moveInDate; - private Boolean isParkingAvailable; - - // 이미지 - private List imageUrls; - private Long imageId; // << 이 필드를 추가했습니다. - private String imageUrl; - - // 관리비 항목 - private List maintenanceItems; - - // 시설 항목 - private List options; - private List facilityItemIds; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class MaintenanceFeeItem { - private Long maintenanceId; - private Integer fee; - private String itemName; - } - - // 생성용 from (HomeCreateRequestDto) - public static HomeRegisterVO from(Long userId, HomeCreateRequestDto dto) { - LocalDateTime parsedBuildDate = - dto.getBuildDate() != null ? dto.getBuildDate().atStartOfDay() : null; - - return HomeRegisterVO.builder() - .userId(userId) - .userName(dto.getUserName()) - .addr1(dto.getAddr1()) - .addr2(dto.getAddr2()) - .residenceType(dto.getResidenceType()) - .leaseType(dto.getLeaseType()) - .depositPrice(dto.getDepositPrice()) - .monthlyRent(dto.getMonthlyRent()) - .maintenanceFee(dto.getMaintenanceFee()) - .supplyArea(dto.getSupplyArea() != null ? dto.getSupplyArea() : 0f) - .exclusiveArea(dto.getExclusiveArea()) - .homeFloor(dto.getHomeFloor()) - .roomCnt(dto.getRoomCnt()) - .bathroomCount(dto.getBathroomCount()) - .facilityItemIds(dto.getFacilityItemIds()) - .buildDate(parsedBuildDate) - .options(dto.getOptions()) - .isParkingAvailable(dto.getIsParkingAvailable()) - .buildingTotalFloors(dto.getBuildingTotalFloors()) - .isPet(dto.getIsPet()) - .moveInDate(dto.getMoveInDate()) - .maintenanceItems(dto.getMaintenanceFeeItems()) - .imageUrls(dto.getImageUrls()) - .build(); - } - - // 수정용 from (HomeUpdateRequestDto) - public static HomeRegisterVO from(Long userId, HomeUpdateRequestDto dto) { - LocalDateTime parsedBuildDate = - dto.getBuildDate() != null ? dto.getBuildDate().atStartOfDay() : null; - - return HomeRegisterVO.builder() - .homeId(dto.getHomeId()) - .userId(userId) - .userName(dto.getUserName()) - .addr1(dto.getAddr1()) - .addr2(dto.getAddr2()) - .residenceType(dto.getResidenceType()) - .leaseType(dto.getLeaseType()) - .depositPrice(dto.getDepositPrice()) - .monthlyRent(dto.getMonthlyRent()) - .maintenanceFee(dto.getMaintenanceFee()) - .supplyArea(dto.getSupplyArea()) - .exclusiveArea(dto.getExclusiveArea()) - .homeFloor(dto.getHomeFloor()) - .roomCnt(dto.getRoomCnt()) - .bathroomCount(dto.getBathroomCount()) - .homeDirection(parseHomeDirection(dto.getHomeDirection())) - .imageUrls(dto.getImageUrls()) - .facilityItemIds(dto.getFacilityItemIds()) - .options(dto.getOptions()) - .isParkingAvailable(dto.getIsParkingAvailable()) - .buildingTotalFloors(dto.getBuildingTotalFloors()) - .isPet(dto.getIsPet()) - .moveInDate(dto.getMoveInDate()) - .buildDate(parsedBuildDate) - .maintenanceItems(dto.getMaintenanceFeeItems()) - .build(); - } - - private static HomeDirection parseHomeDirection(String direction) { - try { - return HomeDirection.valueOf(direction); - } catch (IllegalArgumentException e) { - return null; - } - } -} From 0979b4f1b4ee7f8cc62881b444544aa6fafc59b9 Mon Sep 17 00:00:00 2001 From: leeedongjaee Date: Sun, 17 Aug 2025 12:39:43 +0900 Subject: [PATCH 33/34] =?UTF-8?q?=E2=9C=A8=20feat:=20Home=20dto=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/scoula/domain/home/dto/request/HomeCreateRequestDto.java | 1 + src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java index e69de29b..8b137891 100644 --- a/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/request/HomeCreateRequestDto.java @@ -0,0 +1 @@ + diff --git a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java b/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java index e69de29b..8b137891 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeRegisterVO.java @@ -0,0 +1 @@ + From ed9a86e19d58a4adac8235a3106f0c773398f192 Mon Sep 17 00:00:00 2001 From: seonju21 Date: Sun, 17 Aug 2025 18:06:53 +0900 Subject: [PATCH 34/34] =?UTF-8?q?=E2=99=BB=20refactor:=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EC=83=81=EC=84=B8=EC=84=A4=EB=AA=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeControllerImpl.java | 5 +++++ .../scoula/domain/home/dto/HomeCreateDTO.java | 2 ++ .../domain/home/dto/HomeCreateRequestDto.java | 3 +++ .../domain/home/dto/HomeResponseDTO.java | 2 +- .../domain/home/service/HomeServiceImpl.java | 22 ++++++++++++++++++- .../scoula/domain/home/vo/HomeDetailVO.java | 1 + .../scoula/domain/home/mapper/HomeMapper.xml | 6 ++--- 7 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java index c28bce16..486ce1b5 100644 --- a/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java +++ b/src/main/java/org/scoula/domain/home/controller/HomeControllerImpl.java @@ -42,6 +42,10 @@ public class HomeControllerImpl implements HomeController { public ResponseEntity> createHome( @Valid @RequestPart HomeCreateRequestDto requestDto, Authentication authentication) { + // ✨ 매물 등록 요청 데이터와 description 값을 로그로 확인 + log.info("매물 등록 요청 데이터: {}", requestDto); + log.info("컨트롤러에서 확인한 description 값: {}", requestDto.getDescription()); + Integer userId = getCurrentUserId(authentication); // 이미지 파일 리스트 처리 @@ -70,6 +74,7 @@ public ResponseEntity> createHome( .isPet(requestDto.getIsPet()) .isParking(requestDto.getIsParking()) .facilityItemIds(requestDto.getFacilityItemIds()) + .description(requestDto.getDescription()) .maintenanceFees( requestDto.getMaintenanceFees() != null ? requestDto.getMaintenanceFees().stream() diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java index 04eb02f8..469856c2 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateDTO.java @@ -61,6 +61,8 @@ public class HomeCreateDTO { private List maintenanceFees; + private String description; + @Data @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java index 074f1096..dce35200 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeCreateRequestDto.java @@ -98,6 +98,9 @@ public class HomeCreateRequestDto { @ApiModelProperty(value = "매물 이미지 목록") private List images; + @ApiModelProperty(value = "상세 정보", example = "남향이라 햇살이 잘 들고, 근처에 공원이 있어 산책하기 좋습니다.") + private String description; + @ApiModel(description = "관리비 항목 DTO") @Data @NoArgsConstructor diff --git a/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java b/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java index a2fbd902..77a20b27 100644 --- a/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java +++ b/src/main/java/org/scoula/domain/home/dto/HomeResponseDTO.java @@ -52,7 +52,7 @@ public class HomeResponseDTO { private Boolean isParking; private List facilities; - + private String description; private List maintenanceFees; private List imageUrls; diff --git a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java index 57342ffe..68f31bbb 100644 --- a/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java +++ b/src/main/java/org/scoula/domain/home/service/HomeServiceImpl.java @@ -37,6 +37,10 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I createDTO.getResidenceType(), images != null ? images.size() : 0); + // ✨ 서비스에서 받은 DTO와 description 값 확인 + log.info("서비스에서 받은 DTO: {}", createDTO); + log.info("서비스에서 확인한 description 값: {}", createDTO.getDescription()); + try { HomeVO home = HomeVO.builder() @@ -79,8 +83,13 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I .isParking(createDTO.getIsParking()) .area(createDTO.getArea()) .landCategory(createDTO.getLandCategory()) + .description(createDTO.getDescription()) .build(); + // ✨ DB에 저장할 객체의 description 값 최종 확인 + log.info("DB에 저장할 HomeDetailVO 객체: {}", homeDetail); + log.info("최종 DB 저장 직전의 description 값: {}", homeDetail.getDescription()); + int detailResult = homeMapper.insertHomeDetail(homeDetail); if (detailResult != 1) { throw new BusinessException( @@ -158,7 +167,11 @@ public Integer createHome(HomeCreateDTO createDTO, List images, I return homeId; } catch (Exception e) { - log.error("매물 등록 중 오류 발생: userId={}, error={}", userId, e.getMessage()); + log.error( + "매물 등록 중 오류 발생: userId={}, error={}", + userId, + e.getMessage(), + e); // ⭐️ 예외 객체(e)를 함께 출력하여 스택 트레이스 확인 throw new BusinessException( CommonErrorCode.INTERNAL_SERVER_ERROR, "매물 등록 중 오류가 발생했습니다: " + e.getMessage()); } @@ -188,6 +201,12 @@ public HomeResponseDTO getHome(Integer homeId) { HomeDetailVO homeDetail = homeMapper.selectHomeDetailByHomeId(homeId); + // ✨ 매퍼에서 반환된 객체와 description 값 확인 + log.info("매퍼에서 반환된 HomeDetailVO: {}", homeDetail); + log.info( + "매퍼에서 확인한 description 값: {}", + homeDetail != null ? homeDetail.getDescription() : null); + List images = homeMapper.selectHomeImagesByHomeId(homeId); List imageUrls = images.stream().map(HomeImageVO::getImageUrl).collect(Collectors.toList()); @@ -229,6 +248,7 @@ public HomeResponseDTO getHome(Integer homeId) { .isParking(homeDetail != null ? homeDetail.getIsParking() : null) .facilities(facilities) .maintenanceFees(maintenanceFees) + .description(homeDetail != null ? homeDetail.getDescription() : null) .imageUrls(imageUrls) .createdAt(home.getCreatedAt()) .updatedAt(home.getUpdatedAt()) diff --git a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java index d473c483..ee33fe86 100644 --- a/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java +++ b/src/main/java/org/scoula/domain/home/vo/HomeDetailVO.java @@ -24,4 +24,5 @@ public class HomeDetailVO { private Boolean isParking; private Float area; private String landCategory; + private String description; } diff --git a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml index d7f73b8a..347ec163 100644 --- a/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml +++ b/src/main/resources/org/scoula/domain/home/mapper/HomeMapper.xml @@ -18,11 +18,11 @@ INSERT INTO home_detail ( home_id, build_date, home_floor, building_total_floors, - home_direction, bathroom_count, is_pet, is_parking_available,area,land_category + home_direction, bathroom_count, is_pet, is_parking_available,area,land_category,description ) VALUES ( #{homeId}, #{buildDate}, #{homeFloor}, #{buildingTotalFloors}, - #{homeDirection}, #{bathroomCnt}, #{isPet}, #{isParking}, #{area}, #{landCategory} + #{homeDirection}, #{bathroomCnt}, #{isPet}, #{isParking}, #{area}, #{landCategory},#{description} ) @@ -63,7 +63,7 @@