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 8472c06f..994aa9cd 100644 --- a/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java +++ b/src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.scoula.domain.chat.document.ContractChatDocument; @@ -30,11 +31,11 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import lombok.extern.slf4j.Slf4j; +import lombok.extern.log4j.Log4j2; @RestController @RequestMapping("/api/chat/contract") -@Slf4j +@Log4j2 public class ContractChatControllerImpl implements ContractChatController { private final ContractChatServiceInterface contractChatService; @@ -302,11 +303,26 @@ public ResponseEntity> setEndPointAndExpor @MessageMapping("/contract/chat/enter") public void enterContractChatRoom(@Payload Map payload, Principal principal) { try { + log.info("=== WebSocket 계약 채팅방 입장 시작 ==="); + log.info("payload: {}", payload); + log.info("principal: {}", principal != null ? principal.getName() : "null"); + log.info("Thread: {}", Thread.currentThread().getName()); + Long userId = payload.get("userId"); Long contractChatId = payload.get("contractChatId"); + + log.info("추출된 userId: {}, contractChatId: {}", userId, contractChatId); + + if (userId == null || contractChatId == null) { + log.error("필수 파라미터 누락 - userId: {}, contractChatId: {}", userId, contractChatId); + return; + } + contractChatService.enterContractChatRoom(contractChatId, userId); notifyContractChatOnlineStatus(contractChatId, userId, true); + + log.info("=== WebSocket 계약 채팅방 입장 완료 ==="); } catch (Exception e) { log.error("계약 채팅방 입장 실패", e); } @@ -418,7 +434,7 @@ public ResponseEntity>> getContractChatInfo( if (contractChat == null) { throw new BusinessException(ChatErrorCode.CHAT_ROOM_NOT_FOUND); } - String role = userId == contractChat.getOwnerId() ? "임대인입니다" : "임차인입니다"; + String role = Objects.equals(userId, contractChat.getOwnerId()) ? "임대인" : "임차인"; Map contractInfo = Map.of( @@ -938,8 +954,24 @@ public ResponseEntity>> acceptFinalContractConfi } } - @Override - @GetMapping("/{contractChatId}/status") + // 디버깅용 임시 엔드포인트 + @PostMapping("/{contractChatId}/debug/enter") + public ResponseEntity> debugEnterContractChatRoom( + @PathVariable Long contractChatId, Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + log.info("=== HTTP 디버그 계약 채팅방 입장 ==="); + log.info("contractChatId: {}, userId: {}", contractChatId, userId); + + contractChatService.enterContractChatRoom(contractChatId, userId); + + return ResponseEntity.ok(ApiResponse.success("입장 완료")); + } catch (Exception e) { + log.error("디버그 입장 실패", e); + return ResponseEntity.badRequest().body(ApiResponse.error("입장 실패: " + e.getMessage())); + } + } + public ResponseEntity> getContractStatus( @PathVariable Long contractChatId, Authentication authentication) { Long userId = getUserIdFromAuthentication(authentication); 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 480f9869..7cb96e0c 100644 --- a/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java @@ -839,7 +839,13 @@ public Long acceptContractRequest(Long chatRoomId, Long userId) { .build(); handleChatMessage(acceptMessage); - String contractChatUrl = URL + PRECONTRACTURL + (contractChatRoomId.toString()) + BUYERURL; + String contractChatUrl = + URL + + PRECONTRACTURL + + (contractChatRoomId.toString()) + + BUYERURL + + "&homeId=" + + (originalChatRoom.getHomeId()); ChatMessageRequestDto linkMessage = ChatMessageRequestDto.builder() 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 1969f57b..aa212110 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -17,12 +17,16 @@ import org.scoula.domain.chat.repository.SpecialContractMongoRepository; import org.scoula.domain.chat.vo.ChatRoom; import org.scoula.domain.chat.vo.ContractChat; +import org.scoula.domain.contract.dto.LegalityDTO; +import org.scoula.domain.contract.repository.ContractMongoRepository; +import org.scoula.domain.contract.service.ContractFixServiceInterface; import org.scoula.domain.precontract.service.PreContractDataService; import org.scoula.global.common.exception.BusinessException; import org.scoula.global.common.exception.EntityNotFoundException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.*; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,11 +34,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import lombok.extern.log4j.Log4j2; @Service @RequiredArgsConstructor -@Slf4j +@Log4j2 public class ContractChatServiceImpl implements ContractChatServiceInterface { private final ContractChatMapper contractChatMapper; @@ -42,9 +46,10 @@ public class ContractChatServiceImpl implements ContractChatServiceInterface { private final ContractChatMessageRepository contractChatMessageRepository; private final SimpMessagingTemplate messagingTemplate; private final ChatServiceInterface chatService; + private final ContractMongoRepository contractMongoRepository; private final AiClauseImproveService aiClauseImproveService; private final PreContractDataService preContractDataService; - + private final ContractFixServiceInterface contractFixService; private final Map> contractChatOnlineUsers = new ConcurrentHashMap<>(); private final RedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -99,6 +104,26 @@ public void handleContractChatMessage(ContractChatMessageRequestDto dto) { if (!isUserInContractChat(dto.getContractChatId(), dto.getSenderId())) { throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); } + enterContractChatRoom(dto.getContractChatId(), dto.getSenderId()); + + boolean canSend = canSendContractMessage(dto.getContractChatId()); + if (!canSend) { + log.warn( + "메시지 전송 차단 - contractChatId: {}, senderId: {}", + dto.getContractChatId(), + dto.getSenderId()); + + // 에러 메시지를 발송자에게만 전송 (저장하지 않음) + Map errorInfo = + Map.of( + "error", "OFFLINE_USER", + "message", "상대방이 오프라인 상태입니다. 상대방이 접속한 후 메시지를 보내주세요."); + + messagingTemplate.convertAndSendToUser( + dto.getSenderId().toString(), "/queue/contract/error", errorInfo); + + return; + } ContractChatDocument messageDocument = ContractChatDocument.builder() @@ -175,6 +200,23 @@ public void AiMessageBtn(Long contractChatId, String content) { messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, aiMessage); } + public void AiMessageLegal(Long contractChatId, String content) { + final Long ai = 9996L; + + 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) { @@ -452,23 +494,46 @@ public boolean isUserInContractChat(Long contractChatId, Long userId) { @Override @Transactional public void enterContractChatRoom(Long contractChatId, Long userId) { + log.info("=== enterContractChatRoom 시작 ==="); + log.info("contractChatId: {}, userId: {}", contractChatId, userId); + if (!isUserInContractChat(contractChatId, userId)) { throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); } - setContractChatUserOnline(userId, contractChatId); + // 방 멤버십 Set에 사용자 추가 + stringRedisTemplate.opsForSet().add(roomKey(contractChatId), userId.toString()); + // 사용자 현재 방 Key에 방 ID 저장 (역참조용) + stringRedisTemplate + .opsForValue() + .set(userCurrentRoomKey(userId), contractChatId.toString()); + + broadcastPresence(contractChatId); + log.info("=== enterContractChatRoom 완료 ==="); } /** {@inheritDoc} */ @Override @Transactional public void leaveContractChatRoom(Long contractChatId, Long userId) { + // Redis: 방에서 사용자 제거 및 역참조 정리 + stringRedisTemplate.opsForSet().remove(roomKey(contractChatId), userId.toString()); + stringRedisTemplate.delete(userCurrentRoomKey(userId)); setContractChatUserOffline(userId, contractChatId); + broadcastPresence(contractChatId); + + log.info("====== 사용자 채팅방 퇴장 ======"); } /** {@inheritDoc} */ @Override public Map getContractChatOnlineStatus(Long contractChatId, Long userId) { + log.info("=== getContractChatOnlineStatus(REDIS) 시작 ==="); + log.info("contractChatId: {}, userId: {}", contractChatId, userId); + + // 디버깅용 전체 온라인 사용자 출력 + debugContractChatOnlineUsers(contractChatId); + if (!isUserInContractChat(contractChatId, userId)) { throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); } @@ -485,13 +550,43 @@ public Map getContractChatOnlineStatus(Long contractChatId, Long boolean bothInRoom = ownerInContractRoom && buyerInContractRoom; - return Map.of( - "ownerInContractRoom", ownerInContractRoom, - "buyerInContractRoom", buyerInContractRoom, - "bothInRoom", bothInRoom, - "canChat", bothInRoom, - "ownerId", contractChat.getOwnerId(), - "buyerId", contractChat.getBuyerId()); + log.info( + "Owner({}) 온라인: {}, Buyer({}) 온라인: {}, 둘 다 온라인: {}", + contractChat.getOwnerId(), + ownerInContractRoom, + contractChat.getBuyerId(), + buyerInContractRoom, + bothInRoom); + + Map result = + Map.of( + "ownerInContractRoom", ownerInContractRoom, + "buyerInContractRoom", buyerInContractRoom, + "bothInRoom", bothInRoom, + "canChat", bothInRoom, + "ownerId", contractChat.getOwnerId(), + "buyerId", contractChat.getBuyerId()); + + log.info("=== getContractChatOnlineStatus 완료: {} ===", result); + return result; + } + + // 디버깅용 메서드 (REDIS 기반) + public void debugContractChatOnlineUsers(Long contractChatId) { + log.info("=== 현재 모든 온라인 사용자 상태(REDIS) ==="); + try { + String rKey = roomKey(contractChatId); + Set members = stringRedisTemplate.opsForSet().members(rKey); + log.info("Redis Key: {}, 온라인 사용자(문자열): {}", rKey, members); + if (members != null) { + Set asLongs = members.stream().map(Long::valueOf).collect(Collectors.toSet()); + log.info("계약 채팅방 {} 온라인 사용자(Long): {}", contractChatId, asLongs); + } else { + log.info("계약 채팅방 {} 온라인 사용자 없음", contractChatId); + } + } catch (Exception e) { + log.warn("온라인 사용자 상태 로드 중 오류: {}", e.getMessage()); + } } /** {@inheritDoc} */ @@ -531,18 +626,25 @@ public void setContractUserOffline(Long userId, Long contractChatId) { /** {@inheritDoc} */ private void setContractChatUserOnline(Long userId, Long contractChatId) { - String key = "contract-chat-" + contractChatId; + String key = getContractChatKey(contractChatId); contractChatOnlineUsers .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) .add(userId); + log.debug( + "사용자 {}가 계약 채팅방 {} 온라인 상태로 설정. 현재 온라인 사용자: {}", + userId, + contractChatId, + contractChatOnlineUsers.get(key)); } /** {@inheritDoc} */ private void setContractChatUserOffline(Long userId, Long contractChatId) { - String key = "contract-chat-" + contractChatId; + String key = getContractChatKey(contractChatId); Set users = contractChatOnlineUsers.get(key); if (users != null) { users.remove(userId); + log.debug( + "사용자 {}가 계약 채팅방 {} 오프라인 상태로 설정. 현재 온라인 사용자: {}", userId, contractChatId, users); if (users.isEmpty()) { contractChatOnlineUsers.remove(key); } @@ -551,12 +653,30 @@ private void setContractChatUserOffline(Long userId, Long contractChatId) { /** {@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); + String rKey = roomKey(contractChatId); + Boolean member = stringRedisTemplate.opsForSet().isMember(rKey, userId.toString()); + boolean isOnline = Boolean.TRUE.equals(member); + log.debug( + "사용자 {} 계약 채팅방 {} 온라인 상태 확인(REDIS): {}, key={}", + userId, + contractChatId, + isOnline, + rKey); return isOnline; } + private String getContractChatKey(Long contractChatId) { + return "contract-chat-" + contractChatId; + } + + private String roomKey(Long contractChatId) { + return "contract:room:" + contractChatId + ":users"; + } + + private String userCurrentRoomKey(Long userId) { + return "contract:user:" + userId + ":current-room"; + } + /** {@inheritDoc} */ @Override @Transactional @@ -1970,6 +2090,8 @@ public String getContractChatStatus(ContractChat.ContractStatus status) { return "?step=3&round=3"; case ROUND4: return "?step=3&round=4"; + case COMPLETE: + return "?step=3&round=4"; default: return null; } @@ -2351,12 +2473,145 @@ public Map acceptFinalContractConfirmation(Long contractChatId, String confirmationMessage = "🎉 임차인이 최종 특약서를 수락했습니다! 특약서가 확정되었습니다."; AiMessage(contractChatId, confirmationMessage); - AiMessageNext( - contractChatId, - "다음은 마지막 4단계: ‘적법성 검토' 단계입니다.\n" - + "\n" - + "해당 계약 내용을 기준으로 법률적 적합성을 분석할게요. 잠시만 기다려주세요."); + // [적법성 검사] 계약서 1 몽고DB에 특약 저장 + contractFixService.saveSpecialContract(contractChatId, buyerId); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + AiMessageNext(contractChatId, "다음은 마지막 4단계: '적법성 검토' 단계입니다."); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + AiMessage(contractChatId, "AI가 지금까지 작성된 계약서의 적법성을 분석중이에요!\n 잠시만 기다려주세요!"); + + // api/contract/{contractChatId}/legality + try { + log.info("적법성 검사 API 호출 시작 - contractChatId: {}", contractChatId); + Object legalityResponse = contractFixService.getLegality(contractChatId, buyerId); + String sanitizedLegalityResponse; + try { + ObjectMapper objectMapper = new ObjectMapper(); + sanitizedLegalityResponse = objectMapper.writeValueAsString(legalityResponse); + } catch (Exception ex) { + sanitizedLegalityResponse = String.valueOf(legalityResponse); + } + sanitizedLegalityResponse = sanitizedLegalityResponse.replaceAll("[\\r\\n]", " "); + log.info("적법성 검사 응답: {}", sanitizedLegalityResponse); + if (legalityResponse instanceof LegalityDTO) { + LegalityDTO legalityDTO = (LegalityDTO) legalityResponse; + log.info("LegalityDTO로 응답 파싱 성공"); + + // violations 처리 (중첩 구조로 접근) + if (legalityDTO.getData() != null + && legalityDTO.getData().getViolations() != null + && !legalityDTO.getData().getViolations().isEmpty()) { + List violations = legalityDTO.getData().getViolations(); + log.info("위반 사항 발견됨: {}개", violations.size()); + AiMessage(contractChatId, "⚠️ 적법성 검사 결과, 일부 문제점이 발견되었습니다:"); + + for (int i = 0; i < violations.size(); i++) { + LegalityDTO.Violation violation = violations.get(i); + String sanitizedViolation = + violation == null + ? "null" + : violation.toString().replaceAll("[\\r\\n]", " "); + log.info("위반 사항 {}: {}", i + 1, sanitizedViolation); + StringBuilder violationMessage = new StringBuilder(); + + violationMessage.append( + // 관련 법령 + String.format( + "%s\n" + "\n", + violation.getLawName() != null + ? violation.getLawName() + : "정보 없음")); + + violationMessage.append( + // 위반 내용 + String.format( + (i + 1) + ". %s\n" + "\n", + violation.getViolationContent() != null + ? violation.getViolationContent() + : "정보 없음")); + violationMessage.append( + // 설명 + String.format( + "%s\n" + "\n", + violation.getExplanation() != null + ? violation.getExplanation() + : "정보 없음")); + + if (violation.getOriginalClause() != null + && !violation.getOriginalClause().trim().isEmpty()) { + violationMessage.append( + String.format( + "📝 문제가 된 조항\n %s\n", violation.getOriginalClause())); + } + + if (violation.getLegalBasis() != null + && !violation.getLegalBasis().trim().isEmpty()) { + violationMessage.append( + String.format("📚 법적 근거\n %s\n", violation.getLegalBasis())); + } + + if (violation.getImprovementExample() != null + && !violation.getImprovementExample().trim().isEmpty()) { + violationMessage.append( + String.format( + "✅ 개선 방안\n %s\n", violation.getImprovementExample())); + } + String sanitizedMessage = + violationMessage.toString().replaceAll("[\\r\\n]", " "); + log.info("전송할 메시지: {}", sanitizedMessage); + AiMessageLegal(contractChatId, violationMessage.toString()); + + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + AiMessage(contractChatId, "위 문제점들을 검토하시고 필요시 임대인께서 수정 요청을 해주세요."); + } else { + log.info("위반 사항 없음"); + AiMessage(contractChatId, "✅ 적법성 검사 완료! 계약서에 법적 문제가 발견되지 않았습니다."); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + AiMessage(contractChatId, "최종 계약서 서명하러 갈께요!"); + } + } else if (legalityResponse instanceof Map) { + // 기존 Map 처리 로직 + Map responseMap = (Map) legalityResponse; + Object violationsObj = responseMap.get("violations"); + + if (violationsObj instanceof List) { + List> violations = + (List>) violationsObj; + if (!violations.isEmpty()) { + AiMessage(contractChatId, "⚠️ 적법성 검사 결과, 일부 문제점이 발견되었습니다:"); + } else { + AiMessage(contractChatId, "✅ 적법성 검사 완료! 계약서에 법적 문제가 발견되지 않았습니다."); + } + } + } else { + log.warn( + "응답 타입을 인식할 수 없음: {}", + legalityResponse != null ? legalityResponse.getClass() : "null"); + AiMessage(contractChatId, "❌ 적법성 검사 응답 형식을 인식할 수 없습니다."); + } + } catch (Exception e) { + log.error("적법성 검사 결과 처리 중 오류 발생", e); + AiMessage(contractChatId, "❌ 적법성 검사 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } return Map.of( "message", "최종 특약서가 확정되었습니다.", @@ -2553,7 +2808,106 @@ public String getContractChatRoomUrl(Long chatRoomId) { } Long contractChatRoomId = contractChatId.getContractChatId(); String param = getContractChatStatus(contractChatId.getStatus()); + if (contractChatId.getStatus() == ContractChat.ContractStatus.COMPLETE) { + return baseUrl + contractChatUrl + "complete/" + (contractChatRoomId.toString()); + } else { + return baseUrl + contractChatUrl + contractChatRoomId.toString() + param; + } + } + + private void broadcastPresence(Long contractChatId) { + ContractChat c = contractChatMapper.findByContractChatId(contractChatId); + if (c == null) return; + + boolean ownerIn = isUserInContractChatRoom(c.getOwnerId(), contractChatId); + boolean buyerIn = isUserInContractChatRoom(c.getBuyerId(), contractChatId); + boolean both = ownerIn && buyerIn; + + Map payload = + Map.of( + "type", "PRESENCE", + "ownerInContractRoom", ownerIn, + "buyerInContractRoom", buyerIn, + "bothInRoom", both, + "canChat", both, + "ownerId", c.getOwnerId(), + "buyerId", c.getBuyerId()); + messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, payload); + } + + @Override + public void requestFinalContract(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("최종 특약서가 생성되지 않았습니다."); + } + + AiMessageBtn(contractChatId, "임대인이 최종 계약서 확인을 요청하였습니다"); + + String key = "final-contract:request:" + 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 + public Map acceptFinalContract( + Long contractChatId, Long buyerId, Boolean isAccepted) { + 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:request:" + 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, "확정 요청 정보가 유효하지 않습니다."); + } + + stringRedisTemplate.delete(redisKey); + + if (isAccepted) { + contractMongoRepository.clearSpecialContracts(contractChatId); + contractMongoRepository.saveSpecialContract(contractChatId); + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.COMPLETE); + AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!"); + } else { + AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다."); + } - return baseUrl + contractChatUrl + contractChatRoomId.toString() + param; + return Map.of("accepted", isAccepted); } } 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 8824ea28..2b47dee3 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java @@ -301,4 +301,10 @@ Map respondToFinalContractDeletionRequest( String getContractChatRoomUrl(Long chatRoomId); String getContractChatStatus(ContractChat.ContractStatus status); + + /** 임대인이 최종 특약 확정 요청 */ + void requestFinalContract(Long contractChatId, Long ownerId); + + /** 임차인이 최종 특약 확정 수락 */ + Map acceptFinalContract(Long contractChatId, Long buyerId, Boolean isAccepted); } diff --git a/src/main/java/org/scoula/domain/chat/vo/ContractChat.java b/src/main/java/org/scoula/domain/chat/vo/ContractChat.java index 756ed828..56e53b77 100644 --- a/src/main/java/org/scoula/domain/chat/vo/ContractChat.java +++ b/src/main/java/org/scoula/domain/chat/vo/ContractChat.java @@ -30,7 +30,8 @@ public enum ContractStatus { ROUND2, ROUND3, ROUND4, - STEP4 + STEP4, + COMPLETE } // 현재 라운드 번호 계산 메서드 diff --git a/src/main/java/org/scoula/domain/contract/controller/ContractController.java b/src/main/java/org/scoula/domain/contract/controller/ContractController.java index b5d892f2..42a91c76 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractController.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractController.java @@ -1,9 +1,13 @@ package org.scoula.domain.contract.controller; +import java.util.Map; + +import org.scoula.domain.chat.dto.FinalContractDeletionResponseDto; import org.scoula.domain.contract.dto.*; import org.scoula.global.auth.dto.CustomUserDetails; import org.scoula.global.common.dto.ApiResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -114,4 +118,14 @@ ResponseEntity> updateSpecialContract( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody SpecialContractUpdateDTO dto); + + @ApiOperation(value = "최종 계약서 확정 요청 (임대인)", notes = "임대인이 최종 특약서에 대한 확정을 요청합니다.") + ResponseEntity> requestFinalContract( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 계약서 확정 수락 (임차인)", notes = "임차인이 임대인의 최종 특약서 확정 요청을 수락합니다.") + ResponseEntity>> acceptFinalContract( + @PathVariable Long contractChatId, + @RequestBody FinalContractDeletionResponseDto responseDto, + Authentication authentication); } diff --git a/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java b/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java index 7c94e640..25842dca 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java @@ -1,10 +1,22 @@ package org.scoula.domain.contract.controller; +import java.util.Map; +import java.util.Optional; + +import org.scoula.domain.chat.dto.FinalContractDeletionResponseDto; +import org.scoula.domain.chat.exception.ChatErrorCode; +import org.scoula.domain.chat.service.ContractChatServiceInterface; import org.scoula.domain.contract.dto.*; +import org.scoula.domain.contract.service.ContractFixServiceInterface; import org.scoula.domain.contract.service.ContractService; +import org.scoula.domain.user.service.UserServiceInterface; +import org.scoula.domain.user.vo.User; import org.scoula.global.auth.dto.CustomUserDetails; import org.scoula.global.common.dto.ApiResponse; +import org.scoula.global.common.exception.BusinessException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -17,7 +29,21 @@ @RequestMapping("/api/contract/{contractChatId}") public class ContractControllerImpl implements ContractController { + private final ContractFixServiceInterface contractFixService; + private final UserServiceInterface userService; private final ContractService service; + private final ContractChatServiceInterface contractChatService; + + private Long getUserIdFromAuthentication(Authentication authentication) { + String currentUserEmail = authentication.getName(); + Optional currentUserOpt = userService.findByEmail(currentUserEmail); + + if (currentUserOpt.isEmpty()) { + throw new BusinessException(ChatErrorCode.USER_NOT_FOUND); + } + + return currentUserOpt.get().getUserId(); + } @Override @PostMapping("") @@ -70,7 +96,7 @@ public ResponseEntity> getDepositPrice( } @Override - @PostMapping("/price") + @PostMapping("/price/request") public ResponseEntity> saveDepositPrice( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails, @@ -81,7 +107,7 @@ public ResponseEntity> saveDepositPrice( } @Override - @DeleteMapping("/price") + @PostMapping("/price/reject") public ResponseEntity> deleteDepositPrice( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -91,7 +117,7 @@ public ResponseEntity> deleteDepositPrice( } @Override - @PatchMapping("/price") + @PatchMapping("/price/accept") public ResponseEntity> updateDepositPrice( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -100,16 +126,6 @@ public ResponseEntity> updateDepositPrice( service.updateDepositPrice(contractChatId, userDetails.getUserId()))); } - // @Override - // @PostMapping("/getContracts") - // public ResponseEntity> getContracts( - // @PathVariable Long contractChatId, - // @AuthenticationPrincipal CustomUserDetails userDetails) { - // return ResponseEntity.ok( - // ApiResponse.success(service.getContract(contractChatId, - // userDetails.getUserId()))); - // } - @Override @PostMapping("/save/special-contract") public ResponseEntity> saveSpecialContract( @@ -117,7 +133,8 @@ public ResponseEntity> saveSpecialContract( @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok( ApiResponse.success( - service.saveSpecialContract(contractChatId, userDetails.getUserId()))); + contractFixService.saveSpecialContract( + contractChatId, userDetails.getUserId()))); } @Override @@ -126,7 +143,8 @@ public ResponseEntity> getLegality( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok( - ApiResponse.success(service.getLegality(contractChatId, userDetails.getUserId()))); + ApiResponse.success( + contractFixService.getLegality(contractChatId, userDetails.getUserId()))); } @Override @@ -191,4 +209,47 @@ public ResponseEntity> sendStep4( return ResponseEntity.ok( ApiResponse.success(service.sendStep4(contractChatId, userDetails.getUserId()))); } + + @Override + @PostMapping("/specialContract/final-request") + public ResponseEntity> requestFinalContract( + @PathVariable Long contractChatId, Authentication authentication) { + + try { + Long userId = getUserIdFromAuthentication(authentication); + contractChatService.requestFinalContract(contractChatId, userId); + return ResponseEntity.ok(ApiResponse.success("최종 특약 확정 요청이 임차인에게 전송되었습니다.")); + + } catch (BusinessException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + log.error("최종 특약서 확정 요청 처리 중 오류 발생", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("서버 오류가 발생했습니다.")); + } + } + + @Override + @PostMapping("/specialContract/final-accept") + public ResponseEntity>> acceptFinalContract( + @PathVariable Long contractChatId, + @RequestBody FinalContractDeletionResponseDto responseDto, + Authentication authentication) { + + try { + Long userId = getUserIdFromAuthentication(authentication); + + Map result = + contractChatService.acceptFinalContract( + contractChatId, userId, responseDto.isAccepted()); + return ResponseEntity.ok(ApiResponse.success(result)); + + } catch (BusinessException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + log.error("최종 특약서 확정 수락 처리 중 오류 발생", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("서버 오류가 발생했습니다.")); + } + } } diff --git a/src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java b/src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java index 56c3f489..e4abd69f 100644 --- a/src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java +++ b/src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java @@ -1,37 +1,31 @@ package org.scoula.domain.contract.dto; +import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import io.swagger.annotations.ApiModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -@ApiModel(description = "AI에서 가져온 적법성 검사") @Data -@JsonInclude(JsonInclude.Include.NON_NULL) @Builder @NoArgsConstructor @AllArgsConstructor public class LegalityDTO { - // ⬇⬇ 샘플 JSON의 최상위 구조와 동일 private Boolean success; private String message; - private Payload data; // 기존 Data → Payload로 명칭만 변경 (상관없음) - private Object error; // null 또는 객체/문자열일 수 있어 Object 권장 - private String timestamp; // "2025-08-11T14:39:41" 같은 문자열 + private Payload data; // 다시 Payload 구조로 + private Object error; + private String timestamp; - @lombok.Data + @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class Payload { - private Boolean success; - @JsonProperty("contract_chat_id") private Long contractChatId; @@ -41,30 +35,13 @@ public static class Payload { @JsonProperty("total_violations") private Integer totalViolations; - @JsonProperty("violation_summary") - private ViolationSummary violationSummary; // 샘플엔 없지만 올 수 있으니 optional - - private List violations; + @Builder.Default private List violations = new ArrayList<>(); // 기본값 설정 @JsonProperty("validated_at") private String validatedAt; - - private String recommendation; // optional - } - - @lombok.Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class ViolationSummary { - @JsonProperty("illegal_count") - private Integer illegalCount; - - @JsonProperty("caution_count") - private Integer cautionCount; } - @lombok.Data + @Data @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java b/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java index ed9f4fa7..8b8b3681 100644 --- a/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java +++ b/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java @@ -1,6 +1,7 @@ package org.scoula.domain.contract.repository; import java.time.LocalDate; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -90,7 +91,7 @@ public void saveSpecialContract(Long contractChatId) { .map( fc -> ContractMongoDocument.SpecialContract.builder() - .order(fc.getOrder() + 1) + .order(fc.getOrder()) .title(fc.getTitle()) .content(fc.getContent()) .build()) @@ -102,6 +103,24 @@ public void saveSpecialContract(Long contractChatId) { mongoTemplate.save(contractDoc); } + public void clearSpecialContracts(Long contractChatId) { + Query contractQuery = new Query(Criteria.where("contractChatId").is(contractChatId)); + ContractMongoDocument contractDoc = + mongoTemplate.findOne(contractQuery, ContractMongoDocument.class); + + if (contractDoc == null) { + throw new BusinessException(ContractException.CONTRACT_GET, "계약서를 찾을 수 없습니다."); + } + + // 특약 내용을 빈 리스트로 설정 + contractDoc.setSpecialContracts(new ArrayList<>()); + + // 저장 + mongoTemplate.save(contractDoc); + + System.out.println("특약 내용 삭제 완료 - contractChatId: " + contractChatId); + } + public void updateSpecialContract(Long contractChatId, SpecialContractUpdateDTO dto) { Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); ContractMongoDocument document = mongoTemplate.findOne(query, ContractMongoDocument.class); diff --git a/src/main/java/org/scoula/domain/contract/service/ContractFixService.java b/src/main/java/org/scoula/domain/contract/service/ContractFixService.java new file mode 100644 index 00000000..1084f801 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/service/ContractFixService.java @@ -0,0 +1,163 @@ +package org.scoula.domain.contract.service; + +import org.scoula.domain.contract.document.ContractMongoDocument; +import org.scoula.domain.contract.dto.ContractDTO; +import org.scoula.domain.contract.dto.LegalityDTO; +import org.scoula.domain.contract.exception.ContractException; +import org.scoula.domain.contract.mapper.ContractMapper; +import org.scoula.domain.contract.repository.ContractMongoRepository; +import org.scoula.domain.precontract.exception.PreContractErrorCode; +import org.scoula.domain.precontract.mapper.TenantPreContractMapper; +import org.scoula.domain.precontract.service.IdentityVerificationService; +import org.scoula.domain.precontract.vo.IdentityVerificationInfoVO; +import org.scoula.global.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class ContractFixService implements ContractFixServiceInterface { + private final ContractMongoRepository repository; + private final RestTemplate restTemplate; + private final IdentityVerificationService identityVerificationService; + + private final ContractMapper contractMapper; + private final TenantPreContractMapper tenantMapper; + + @Value("${ai.server.url:http://localhost:8000}") + private String aiServerUrl; + + @Override + public LegalityDTO getLegality(Long contractChatId, Long userId) { + // userId 검증 + validateUserId(contractChatId, userId); + + // MongoDB에서 전체 부분을 조회한다 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_GET); + } + + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + IdentityVerificationInfoVO ownerVO = + identityVerificationService.getDecryptedVerificationInfo( + contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = + identityVerificationService.getDecryptedVerificationInfo( + contractChatId, buyerContractId); + + ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + + // AI + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/validate"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(dto, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class); + LegalityDTO res = response.getBody(); + assert res != null; + + String resStr; + try { + ObjectMapper objectMapper = new ObjectMapper(); + resStr = objectMapper.writeValueAsString(res); + } catch (Exception ex) { + resStr = res.toString(); + } + // Remove newlines and carriage returns + resStr = resStr.replaceAll("[\\r\\n]", " "); + log.warn("AI 응답 값 확인: {}", resStr); + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return response.getBody(); + } else { + // Sanitize response body before logging to prevent log injection + String responseBodyStr; + try { + ObjectMapper objectMapper = new ObjectMapper(); + responseBodyStr = objectMapper.writeValueAsString(response.getBody()); + } catch (Exception ex) { + responseBodyStr = String.valueOf(response.getBody()); + } + // Remove newlines and carriage returns + responseBodyStr = responseBodyStr.replaceAll("[\\p{Cntrl}]", " "); + log.error("AI server error response (sanitized): {}", responseBodyStr); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR); + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } + + @Override + public Void saveSpecialContract(Long contractChatId, Long userId) { + // userId 검증 + validateUserId(contractChatId, userId); + // 몽고 DB에서 특약부분을 받아서 저장한다. + try { + repository.saveSpecialContract(contractChatId); + } catch (Exception e) { + // 예외 로그 기록 및 사용자에게 전달할 메시지 등 처리 + log.error("특약사항 저장 실패 ❌", e); + throw new BusinessException(ContractException.CONTRACT_INSERT, e); + } + return null; + } + + public void validateUserId(Long contractChatId, Long userId) { + + if (userId == null) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + if (userId.equals(ownerContractId)) { + validateIsOwner(contractChatId, userId); + return; + } + + if (userId.equals(buyerContractId)) { + Long buyerId = + tenantMapper + .selectContractBuyerId(contractChatId) + .orElseThrow( + () -> new BusinessException(PreContractErrorCode.TENANT_USER)); + + if (!userId.equals(buyerId)) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + return; + } + + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + + public void validateIsOwner(Long contractChatId, Long userId) { + Long ownerId = + tenantMapper + .selectContractOwnerId(contractChatId) + .orElseThrow(() -> new BusinessException(PreContractErrorCode.TENANT_USER)); + if (!userId.equals(ownerId)) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + } +} diff --git a/src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java b/src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java new file mode 100644 index 00000000..3e81a7c9 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java @@ -0,0 +1,10 @@ +package org.scoula.domain.contract.service; + +import org.scoula.domain.contract.dto.LegalityDTO; + +public interface ContractFixServiceInterface { + + Void saveSpecialContract(Long contractChatId, Long userId); + + LegalityDTO getLegality(Long contractChatId, Long userId); +} diff --git a/src/main/java/org/scoula/domain/contract/service/ContractService.java b/src/main/java/org/scoula/domain/contract/service/ContractService.java index e875661d..03f334c7 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractService.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractService.java @@ -72,25 +72,6 @@ public interface ContractService { */ Void updateDepositPrice(Long contractChatId, Long userId); - // /** - // * @param contractChatId 채팅방 아이디 - // * @param userId 유저 아이디 - // * @return 계약서 내용을 보내기 - // */ - // ContractDTO getContracts(Long contractChatId, Long userId); - - /** * step4 start 특약을 개약 테이블에 저장하기 * * @param contractChatId 채팅방 아이디 * @param userId 유저 아이디 */ - Void saveSpecialContract(Long contractChatId, Long userId); - - /** - * step4 (init) 계약서를 AI로 보내고, 적법성 받기 - * - * @param contractChatId 채팅방 아이디 - * @param userId 유저 아이디 - * @return AI가 계약서를 보고 주는 적법성을 리턴값으로 보내기 - */ - LegalityDTO getLegality(Long contractChatId, Long userId); - /** * step4 적법성 검사 후 수정된 특약으로 변경 * diff --git a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java index 0b025deb..91828df4 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; +import org.springframework.context.annotation.Lazy; import org.scoula.domain.chat.mapper.ContractChatMapper; import org.scoula.domain.chat.service.ContractChatServiceInterface; import org.scoula.domain.chat.vo.ContractChat; @@ -38,22 +39,20 @@ @Log4j2 public class ContractServiceImpl implements ContractService { - private final ContractChatServiceInterface contractChatService; + private final @Lazy ContractChatServiceInterface contractChatService; - private final ContractMapper contractMapper; private final ContractMongoRepository repository; - private final RestTemplate restTemplate; - private final TenantPreContractMapper tenantMapper; - private final ContractChatMapper contractChatMapper; private final IdentityVerificationService identityVerificationService; + private final ContractChatMapper contractChatMapper; + private final ContractMapper contractMapper; + private final TenantPreContractMapper tenantMapper; private final ObjectMapper objectMapper = new ObjectMapper(); private final RedisTemplate stringRedisTemplate; private final S3ServiceImpl s3Service; private final EmailServiceImpl emailService; - @Value("${ai.server.url:http://localhost:8000}") - private String aiServerUrl; + /** {@inheritDoc} */ @Override @@ -138,13 +137,15 @@ public Void getContractNext(Long contractChatId, Long userId) { contractChatService.AiMessage( contractChatId, """ - 👋🏻 안녕하세요! + 🎉 안녕하세요! 이 계약은 임대인 %s님과 임차인 %s님의 계약입니다. - 시작하기 전, 정보를 먼저 확인할게요. - 제출된 정보를 토대로 계약서를 추출할게요. + 이번 단계는 정보 확인 단계에요. """.formatted(aiDto.getOwnerName(), aiDto.getBuyerName()) ); + // 스텝 변경 + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP0); + // 2초 대기 try { Thread.sleep(2000); @@ -154,14 +155,22 @@ public Void getContractNext(Long contractChatId, Long userId) { contractChatService.AiMessageBtn(contractChatId, """ %s님과 %s님이 작성한 사전 조사를 토대로 - 정보를 추출한 결과가 다음과 같습니다. - 매물 정보, 조건을 확인하셨나요? + 정보를 추출한 결과, 👉오른쪽 계약서와 같아요. + 🏠매물 정보, 조건을 확인해주세요. 다음 단계로 넘어갈까요? - """.formatted(aiDto.getBuyerName(), aiDto.getOwnerName())); + """.formatted(aiDto.getOwnerName(), aiDto.getBuyerName())); return null; } + String step3StartMessage = "다음은 3단계: ‘특약 조율' 단계입니다.\n" + + "\n" + + "'특약'은 계약 당사자 간의 특별한 상호 합의로서 명확한 권리, 의무 관계를 명시해야 해요. \n" + + "\n" + + "하지만, 특약으로 기재했다고 모든 조항이 효력을 갖는 것이 아니에요. \n" + + "\n" + + "주택임대차보호법의 범위를 넘어서지 않도록 AI가 도와줄게요."; + @Override public Boolean nextStep(Long contractChatId, Long userId, NextStepDTO dto) { @@ -173,8 +182,11 @@ public Boolean nextStep(Long contractChatId, Long userId, NextStepDTO dto) { if (nextSteps) { boolean deposit = contractMapper.getDepositAdjustment(contractChatId); + // 스텝 변경 + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP1); + if (deposit) { - contractChatService.AiMessage(contractChatId, "다음 단계는 '금액 조율' 단계입니다"); + contractChatService.AiMessage(contractChatId, "다음은 2단계 '금액 조율' 단계입니다."); } else if (!deposit) { contractChatService.AiMessageBtn(contractChatId, """ 다음은 2단계 '금액 조율' 단계입니다. @@ -186,15 +198,22 @@ public Boolean nextStep(Long contractChatId, Long userId, NextStepDTO dto) { // 2초 대기 try { Thread.sleep(2000); + + // 다음 단계 메세지 보내기 + contractChatService.AiMessage(contractChatId, step3StartMessage); + + // 스텝 변경 + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP2); + Thread.sleep(2000); + + // 특약 초안 메시지 + contractChatService.AiMessageBtn(contractChatId, "특약 초안이 생성되었습니다. 각 조항을 검토하고 수락 / 거절을 선택하세요."); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - // 다음 단계 메세지 보내기 - contractChatService.AiMessage(contractChatId, "이번 단계는 '금액 조율' 단계입니다"); } -// // 스텝 변경 -// contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP1); + } return nextSteps; @@ -212,27 +231,30 @@ public PaymentDTO getDepositPrice(Long contractChatId, Long userId) { AIMessageDTO aiDto = AIMessageDTO.toDTO(doc); long contract = ChronoUnit.YEARS.between(aiDto.getContractStartDate(), aiDto.getContractEndDate()); - String rentType = tenantMapper.selectRentType(contractChatId, userId) + String rentType = tenantMapper.selectRentTypeAll(contractChatId, userId) .orElseThrow(() -> new BusinessException(ContractException.CONTRACT_GET, "전/월세 타입 조회 실패")); - + String rentTypeKr; + if(rentType.equals("JEONSE")){ + rentTypeKr = "전세"; + }else{ + rentTypeKr="월세"; + } // 시작 메세지 보내기 contractChatService.AiMessage( contractChatId, """ - 다음은 2단계: ‘금액 조율’ 단계입니다. - 이 계약은 계약기간 %d년의 %s 계약입니다. - 전세 보증금은 %s, + 보증금은 %s, 관리비는 %s입니다. """.formatted( contract, - rentType, + rentTypeKr, formatWonShort(aiDto.getDepositPrice()), formatWonShort(aiDto.getMaintenanceFee()))); // 대기 try { - Thread.sleep(1000); + Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } @@ -253,28 +275,37 @@ public PaymentDTO getDepositPrice(Long contractChatId, Long userId) { return dto; } - /** {@inheritDoc} */ - @Override - public Void saveDepositPrice(Long contractChatId, Long userId, PaymentDTO dto) { - // Userid 검증 - validateUserId(contractChatId, userId); + /** {@inheritDoc} */ + @Override + public Void saveDepositPrice(Long contractChatId, Long userId, PaymentDTO dto) { + // Userid 검증 + validateUserId(contractChatId, userId); - // 레디스에 내용 저장하기 / value 값 넛기 - String redisKey = "contract:payment:" + contractChatId; - try { - // 3. DTO를 JSON 문자열로 변환 - ObjectMapper objectMapper = new ObjectMapper(); - String json = objectMapper.writeValueAsString(dto); + String redisKey = "contract:payment:" + contractChatId; - // 4. Redis에 저장 - stringRedisTemplate.opsForValue().set(redisKey, json); + String paymentValue = dto.getDepositPrice() + "," + dto.getMonthlyRent(); + stringRedisTemplate.opsForValue().set(redisKey, paymentValue); - } catch (JsonProcessingException e) { - throw new BusinessException(ContractException.CONTRACT_REDIS, e); - } + Long ownerId = contractMapper.getOwnerId(contractChatId); - return null; - } + String userRole = userId.equals(ownerId) ? "임대인" : "임차인"; + + String depositFormatted = formatWonShort(dto.getDepositPrice()); + String monthlyRentFormatted = formatWonShort(dto.getMonthlyRent()); + + String message; + if (dto.getMonthlyRent() > 0) { + message = String.format("%s이 보증금 %s, 월세 %s로 금액 조정을 요청했습니다.", + userRole, depositFormatted, monthlyRentFormatted); + } else { + message = String.format("%s이 전세금 %s로 금액 조정을 요청했습니다.", + userRole, depositFormatted); + } + + contractChatService.AiMessage(contractChatId, message); + + return null; + } /** {@inheritDoc} */ @Override @@ -297,159 +328,72 @@ public Void deleteDepositPrice(Long contractChatId, Long userId) { return null; } - /** {@inheritDoc} */ - @Override - public Void updateDepositPrice(Long contractChatId, Long userId) { - // Userid 검증 - validateUserId(contractChatId, userId); + /** {@inheritDoc} */ + @Override + public Void updateDepositPrice(Long contractChatId, Long userId) { + validateUserId(contractChatId, userId); - // 2. Redis에서 해당 금액 정보 가져오기 - String redisKey = "contract:payment:" + contractChatId; // value : 임대인 id -> 거절시 Delete - String json = stringRedisTemplate.opsForValue().get(redisKey); + String redisKey = "contract:payment:" + contractChatId; + String paymentValue = stringRedisTemplate.opsForValue().get(redisKey); - if (json == null) { - throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); - } + if (paymentValue == null) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); + } - try { - // 3. JSON -> DTO로 변환 - ObjectMapper objectMapper = new ObjectMapper(); - PaymentDTO dto = objectMapper.readValue(json, PaymentDTO.class); + try { + String[] amounts = paymentValue.split(","); + if (amounts.length != 2) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "Redis 금액 데이터 형식이 올바르지 않습니다."); + } - // 4. MongoDB에서 계약서 불러오기 - repository.updateDepositPrice(contractChatId, dto); + int depositPrice = Integer.parseInt(amounts[0]); + int monthlyRent = Integer.parseInt(amounts[1]); - // 7. Redis 값 삭제 - stringRedisTemplate.delete(redisKey); + PaymentDTO dto = PaymentDTO.builder() + .depositPrice(depositPrice) + .monthlyRent(monthlyRent) + .build(); - } catch (Exception e) { - throw new BusinessException(ContractException.CONTRACT_UPDATE, e); - } + repository.updateDepositPrice(contractChatId, dto); - // 스텝 변경 - contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP2); + String depositFormatted = formatWonShort(depositPrice); + String monthlyRentFormatted = formatWonShort(monthlyRent); - // 다음 단계 메세지 보내기 - contractChatService.AiMessage(contractChatId, "이번 단계는 '특약 조율' 단계입니다"); + String acceptMessage; + if (monthlyRent > 0) { + acceptMessage = String.format("금액 조정이 수락되었습니다!\n보증금: %s\n월세: %s", + depositFormatted, monthlyRentFormatted); + } else { + acceptMessage = String.format("금액 조정이 수락되었습니다!\n전세금: %s", depositFormatted); + } - return null; - } + contractChatService.AiMessage(contractChatId, acceptMessage); + stringRedisTemplate.delete(redisKey); - // 적법성 검사 -// @Override -// public ContractDTO getContracts (Long contractChatId, Long userId){ -// // userId 검증 -// validateUserId(contractChatId, userId); -// -// ContractDTO dto; -// -// // 몽고 DB에서 특약부분을 받아서 저장한다. -//// try { -// repository.saveSpecialContract(contractChatId); -// -// ContractMongoDocument document = repository.getContract(contractChatId); -// if (document == null) { -// throw new BusinessException(ContractException.CONTRACT_GET); -// } -// Long ownerContractId = contractMapper.getOwnerId(contractChatId); -// Long buyerContractId = contractMapper.getBuyerId(contractChatId); -// IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); -// IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); -// -// -// // 찾은 값을 Dto에 넣고 반환하기 -// dto = ContractDTO.toDTO(document, ownerVO, buyerVO); -//// } catch (Exception e) { -//// // 예외 로그 기록 및 사용자에게 전달할 메시지 등 처리 -//// log.error("특약사항 저장 실패 ❌", e); -//// throw new BusinessException(ContractException.CONTRACT_INSERT, e); -//// } -// -//// ContractMongoDocument document = repository.getContract(contractChatId); -//// if (document == null) { -//// throw new BusinessException(ContractException.CONTRACT_GET); -//// } -//// -//// // 찾은 값을 Dto에 넣고 반환하기 -//// ContractDTO dto = ContractDTO.toDTO(document); -// -//// ContractDTO dto = getContract(contractChatId, userId); -// -// return dto; -// } + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP2); + Thread.sleep(2000); - @Override - public Void saveSpecialContract(Long contractChatId, Long userId) { - // userId 검증 - validateUserId(contractChatId, userId); - // 몽고 DB에서 특약부분을 받아서 저장한다. - try { repository.saveSpecialContract(contractChatId); } catch (Exception e) { - // 예외 로그 기록 및 사용자에게 전달할 메시지 등 처리 - log.error("특약사항 저장 실패 ❌", e); - throw new BusinessException(ContractException.CONTRACT_INSERT, e); } - return null; - } - /** {@inheritDoc} */ - // ai로 적법성 검사하기 -> 암호화 풀어서 보내기 - @Override - public LegalityDTO getLegality(Long contractChatId, Long userId) { - // userId 검증 - validateUserId(contractChatId, userId); + contractChatService.AiMessage(contractChatId, step3StartMessage); - // MongoDB에서 전체 부분을 조회한다 - ContractMongoDocument document = repository.getContract(contractChatId); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_GET); - } + Thread.sleep(2000); - Long ownerContractId = contractMapper.getOwnerId(contractChatId); - Long buyerContractId = contractMapper.getBuyerId(contractChatId); - IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); - IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + contractChatService.AiMessageBtn(contractChatId, "특약 초안이 생성되었습니다. 각 조항을 검토하고 수락 / 거절을 선택하세요."); - ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + } catch (NumberFormatException e) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "Redis의 금액 데이터를 파싱할 수 없습니다.", e); + } catch (Exception e) { + throw new BusinessException(ContractException.CONTRACT_UPDATE, e); + } - // AI - try { - // AI로 해당 데이터를 넘긴다 (restTemplate 사용) - String url = aiServerUrl + "/api/contract/validate"; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity requestEntity = new HttpEntity<>(dto, headers); - - // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class); - LegalityDTO res = response.getBody(); - assert res != null; - log.warn("AI 응답 값 확인: {}", res.toString()); - - log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return response.getBody(); - } else { - // Sanitize response body before logging to prevent log injection - String responseBodyStr; - try { - ObjectMapper objectMapper = new ObjectMapper(); - responseBodyStr = objectMapper.writeValueAsString(response.getBody()); - } catch (Exception ex) { - responseBodyStr = String.valueOf(response.getBody()); - } - // Remove newlines and carriage returns - responseBodyStr = responseBodyStr.replaceAll("[\\r\\n]", " "); - log.error(responseBodyStr); - throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR); - } + return null; + } + + + /** {@inheritDoc} */ + // ai로 적법성 검사하기 -> 암호화 풀어서 보내기 - } catch (Exception e) { - log.error(e.getMessage()); - throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); - } - } // 임대인 삭제 @Override @@ -559,6 +503,7 @@ public Void updateBuyerLegality(Long contractChatId, Long userId, SpecialContrac return null; } + // 임차인 거절 @Override @Transactional @@ -694,28 +639,32 @@ public Boolean nextSteps(Long contractChatId, Long userId, NextStepDTO dto) { private static String formatWonShort(int amount) { if (amount == 0) return "0원"; + long eok = amount / 100_000_000; // 억 long man = (amount % 100_000_000) / 10_000; // 만원 단위 StringBuilder sb = new StringBuilder(); + if (eok > 0) { sb.append(eok).append("억"); - long cheon = man / 1000; // 천만원 단위 - long remainMan = man % 1000; - if (cheon > 0) sb.append(" ").append(cheon).append("천"); - if (cheon == 0 && remainMan > 0) sb.append(" ").append(remainMan).append("만"); + if (man > 0) { + sb.append(" ").append(man).append("만"); + } sb.append("원"); } else { - if (man >= 1000) { - long cheon = man / 1000; - long remainMan = man % 1000; - sb.append(cheon).append("천"); - if (remainMan > 0) sb.append(" ").append(remainMan).append("만"); - sb.append("원"); - } else { + if (man > 0) { sb.append(man).append("만원"); + } else { + // 만원 미만인 경우 + long cheon = (amount % 10_000) / 1_000; + if (cheon > 0) { + sb.append(cheon).append("천원"); + } else { + sb.append(amount).append("원"); + } } } - return sb.toString().replaceAll("\\s+", " "); + + return sb.toString(); } } diff --git a/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java b/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java index 75d1f395..ebb8b431 100644 --- a/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java +++ b/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java @@ -24,7 +24,8 @@ public interface TenantPreContractMapper { // =============== 사기 위험도 확인 & 기본 세팅 ================== // identity_verification에서 identity_id 가져오기 - Optional selectIdentityId(@Param("userId") Long userId); + Optional selectIdentityId( + @Param("contractChatId") Long contractChatId, @Param("userId") Long userId); // risk_check에 맞는 risk_id가 있는지 확인하기 Optional selectRiskId( @@ -38,6 +39,9 @@ Optional selectRiskType( Optional selectRentType( @Param("contractChatId") Long contractChatId, @Param("userId") Long userid); + Optional selectRentTypeAll( + @Param("contractChatId") Long contractChatId, @Param("userId") Long userid); + // tenant_preCheck_check에 기본 세팅 하기 (나머지는 다 Null) int insertPreContractSet( @Param("contractChatId") Long contractChatId, diff --git a/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java b/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java index ea9a5cb7..40248f07 100644 --- a/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java @@ -440,10 +440,9 @@ public Void saveMongoDB(Long contractChatId, Long userId) { .type("URLLINK") .build(); chatService.handleChatMessage(linkMessages); + contractService.saveContractMongo(contractChatId, userId); contractChatService.AiMessage( contractChatId, "\uD83D\uDC4B 임대인께서 입장하셨습니다! \n" + "지금부터 계약을 진행하겠습니다."); - contractService.saveContractMongo(contractChatId, userId); - contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP0); return null; } diff --git a/src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java b/src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java index a5e2c5fe..a383c093 100644 --- a/src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java +++ b/src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java @@ -88,7 +88,7 @@ public ClauseImproveRequestDto.TenantData fetchTenantData(Long contractChatId) { "Tenant 데이터를 찾을 수 없습니다. contractChatId: " + contractChatId); } - Long identityId = tenantMapper.selectIdentityId(buyerId).orElse(null); + Long identityId = tenantMapper.selectIdentityId(contractChatId, buyerId).orElse(null); ClauseImproveRequestDto.TenantData tenantData = buildTenantData(tenantDto, contractChatId, identityId); diff --git a/src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java b/src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java index 883a1afa..a7d52973 100644 --- a/src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java @@ -3,9 +3,11 @@ import java.util.Optional; import org.scoula.domain.chat.dto.ChatMessageRequestDto; +import org.scoula.domain.chat.mapper.ChatRoomMapper; import org.scoula.domain.chat.mapper.ContractChatMapper; import org.scoula.domain.chat.service.ChatServiceInterface; import org.scoula.domain.chat.service.ContractChatServiceInterface; +import org.scoula.domain.chat.vo.ChatRoom; import org.scoula.domain.chat.vo.ContractChat; import org.scoula.domain.precontract.dto.tenant.*; import org.scoula.domain.precontract.enums.RentType; @@ -34,6 +36,7 @@ public class TenantPreContractServiceImpl implements TenantPreContractService { private final ChatServiceInterface chatService; private final ContractChatMapper contractChatMapper; private final ContractChatServiceInterface contractChatService; + private final ChatRoomMapper chatRoomMapper; @Value("${front.base.url}") private String URL; @@ -82,7 +85,7 @@ public TenantInitRespDTO saveTenantInfo(Long contractChatId, Long userId) { // 1-1. identity_id 가져오기 Long identityId = tenantMapper - .selectIdentityId(userId) + .selectIdentityId(contractChatId, userId) .orElseThrow( () -> new BusinessException(PreContractErrorCode.TENANT_SELECT)); @@ -359,12 +362,21 @@ public Void saveMongoDB(Long contractChatId, Long userId) { } ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - - String contractChatUrls = URL + precontractUrl + (contractChatId.toString()) + ownerUrl; - + ChatRoom chatRoom = + chatRoomMapper.findByUserAndHome( + contractChat.getOwnerId(), + contractChat.getBuyerId(), + contractChat.getHomeId()); + String contractChatUrls = + URL + + precontractUrl + + (contractChatId.toString()) + + ownerUrl + + "&homeId=" + + (contractChat.getHomeId().toString()); ChatMessageRequestDto linkMessages = ChatMessageRequestDto.builder() - .chatRoomId(contractChatId) + .chatRoomId(chatRoom.getChatRoomId()) .senderId(contractChat.getBuyerId()) .receiverId(contractChat.getOwnerId()) .content(contractChatUrls) diff --git a/src/main/java/org/scoula/global/config/ServletConfig.java b/src/main/java/org/scoula/global/config/ServletConfig.java index 1354df14..9905ff7d 100644 --- a/src/main/java/org/scoula/global/config/ServletConfig.java +++ b/src/main/java/org/scoula/global/config/ServletConfig.java @@ -95,4 +95,20 @@ public LocalDate convert(String source) { } }); } + + // @Override + // public void addCorsMappings(CorsRegistry registry) { + // registry.addMapping("/**") + // .allowedOrigins( + // "http://localhost:5173", + // "http://localhost:8080", + // "https://itzeep.ariogi.kr", + // "https://www.itzeep.ariogi.kr", + // "http://itzeep.ariogi.kr", + // "http://www.itzeep.ariogi.kr") + // .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + // .allowedHeaders("*") + // .allowCredentials(true) + // .maxAge(3600); + // } } diff --git a/src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java b/src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java index 06cef247..b66bed3e 100644 --- a/src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java @@ -1,11 +1,24 @@ package org.scoula.global.websocket.config; +import java.util.Collections; + import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import lombok.extern.log4j.Log4j2; + +@Log4j2 @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @@ -21,8 +34,9 @@ public WebSocketConfig() { @Override public void configureMessageBroker(MessageBrokerRegistry config) { System.err.println("🚨🚨🚨 MessageBroker 설정 시작"); - config.enableSimpleBroker("/topic"); + config.enableSimpleBroker("/topic", "/queue"); config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); System.err.println("🚨🚨🚨 MessageBroker 설정 완료"); } @@ -43,4 +57,80 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { .withSockJS(); System.err.println("🚨🚨🚨 STOMP 엔드포인트 등록 완료"); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors( + new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor( + message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + log.info("🔐 WebSocket 연결 시도 - 헤더 확인"); + + // 헤더에서 인증 정보 확인 + String authHeader = accessor.getFirstNativeHeader("Authorization"); + String userId = accessor.getFirstNativeHeader("X-User-Id"); + + log.info( + "🔍 받은 헤더 - Authorization: {}, X-User-Id: {}", + authHeader != null ? "있음" : "없음", + userId); + + // JWT 토큰에서 사용자 ID 추출 + if (authHeader != null && authHeader.startsWith("Bearer ")) { + try { + String token = authHeader.substring(7); + // 간단한 JWT 파싱 (실제로는 JwtUtil 사용 권장) + String[] parts = token.split("\\."); + if (parts.length == 3) { + String payload = + new String( + java.util.Base64.getDecoder() + .decode(parts[1])); + // 페이로드에서 sub (사용자 이메일) 추출 + if (payload.contains("\"sub\"")) { + String[] subParts = payload.split("\"sub\":\""); + if (subParts.length > 1) { + String userEmail = subParts[1].split("\"")[0]; + // Principal 설정 + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + userEmail, + null, + Collections.emptyList()); + accessor.setUser(auth); + log.info( + "✅ WebSocket 인증 성공 (JWT) - User: {}", + userEmail); + } + } + } + } catch (Exception e) { + log.error("❌ JWT 토큰 파싱 실패: {}", e.getMessage()); + } + } + + // X-User-Id 헤더가 있으면 사용 (백업) + if (accessor.getUser() == null && userId != null && !userId.isEmpty()) { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + userId, null, Collections.emptyList()); + accessor.setUser(auth); + } + + log.info( + "🔐 최종 Principal 상태: {}", + accessor.getUser() != null + ? accessor.getUser().getName() + : "null"); + } + + return message; + } + }); + } } diff --git a/src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml b/src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml index 6ea42528..3ade74bd 100644 --- a/src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml +++ b/src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml @@ -143,6 +143,7 @@ FROM contract_chat cc INNER JOIN identity_verification iv ON cc.owner_id = iv.user_id WHERE cc.contract_chat_id = #{contractChatId} + AND iv.contract_id = cc.contract_chat_id diff --git a/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml b/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml index 12c94950..e98cc838 100644 --- a/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml +++ b/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml @@ -33,6 +33,7 @@ INNER JOIN identity_verification iv ON cc.buyer_id = iv.user_id WHERE cc.buyer_id = #{userId} + AND iv.contract_id = #{contractChatId} + + + +