diff --git a/config-submodule b/config-submodule index 821c2c2a..b330f7d8 160000 --- a/config-submodule +++ b/config-submodule @@ -1 +1 @@ -Subproject commit 821c2c2ab5a06ba5e9f3e7ed6f23041d5c36bbec +Subproject commit b330f7d863973acf8c473b0fe532866201a335d9 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..93b67354 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; @@ -134,6 +133,14 @@ ResponseEntity>> getCompletedSpecia ResponseEntity>> getIncompleteSpecialContracts( @PathVariable Long contractChatId, Authentication authentication); + @ApiOperation( + value = "메시지 미포함 미완료 특약 문서 목록 조회", + notes = "메시지 내역이 포함되지 않은 미완료된 모든 특약 문서를 조회합니다.") + @GetMapping("/special-contract/{contractChatId}/incomplete/now") + ResponseEntity>> + getIncompleteSpecialContractsWithoutMessage( + @PathVariable Long contractChatId, Authentication authentication); + @ApiOperation(value = "특약 대화 시작 AI 메시지", notes = "선택된 특약 번호 대화를 시작한다는 메시지를 AI가 전송합니다.") @PostMapping("/special-contract/{contractChatId}/ai") ResponseEntity> sendAiMessage( @@ -143,4 +150,73 @@ 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, + @RequestBody FinalContractDeletionResponseDto responseDto, + 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 = "현재 진행 상황을 조회합니다.") + @GetMapping("/{contractChatId}/status") + ResponseEntity> getContractStatus( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "전체 라운드 특약 문서 조회", notes = "모든 라운드의 특약 문서를 조회합니다.") + @GetMapping("/special-contract/{contractChatId}/all-rounds") + ResponseEntity>> getAllRoundsSpecialContract( + @PathVariable Long contractChatId, Authentication authentication); + + @ApiOperation(value = "최종 특약 삭제 요청 응답", notes = "임차인이 삭제 요청을 수락 또는 거절합니다.") + @PostMapping("/final-contract/{contractChatId}/deletion-response") + ResponseEntity>> respondToFinalContractDeletion( + @PathVariable Long contractChatId, + @RequestBody FinalContractDeletionResponseDto responseDto, + Authentication authentication); + + @ApiOperation(value = "계약 채팅방 URL 이동", notes = "계약 채팅방 URL로 이동하는 API") + @GetMapping("/{chatRoomId}/moveContractChat") + ResponseEntity> moveContractChat( + @PathVariable Long chatRoomId, 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..8472c06f 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; @@ -74,6 +73,15 @@ private Long getUserIdFromAuthentication(Authentication authentication) { return currentUserOpt.get().getUserId(); } + @Override + @GetMapping("/{chatRoomId}/moveContractChat") + public ResponseEntity> moveContractChat( + @PathVariable Long chatRoomId, Authentication authentication) { + Long userId = getUserIdFromAuthentication(authentication); + String url = contractChatService.getContractChatRoomUrl(chatRoomId); + return ResponseEntity.ok(ApiResponse.success(url)); + } + @Override @PostMapping("/rooms") public ResponseEntity> createContractChat( @@ -152,7 +160,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 +290,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 +449,6 @@ public ResponseEntity>> getContractChatInfo( } // 특약 관련 메서드 - // 수정된 ContractChatControllerImpl.java 구현 메서드들 - // ApiResponse 사용법을 기존 프로젝트에 맞게 수정 - @Override @PostMapping("/special-contracts/{contractChatId}/submit-selection") public ResponseEntity> submitSpecialContractSelection( @@ -497,6 +494,7 @@ public ResponseEntity> createSpecialCont } } + @Override @GetMapping("/special-contract/{contractChatId}/all-rounds") public ResponseEntity>> getAllRoundsSpecialContract( @PathVariable Long contractChatId, Authentication authentication) { @@ -548,6 +546,7 @@ public ResponseEntity> getSpecialContrac } } + @Override @PutMapping("/special-contract/{contractChatId}/recent") public ResponseEntity> updateRecentData( @PathVariable Long contractChatId, @@ -646,7 +645,6 @@ public ResponseEntity> completeSpecialCo } } - // 컨트롤러에 추가 @Override @GetMapping("/special-contract/{contractChatId}/incomplete") public ResponseEntity>> @@ -665,6 +663,25 @@ public ResponseEntity> completeSpecialCo } } + @Override + @GetMapping("/special-contract/{contractChatId}/incomplete/now") + public ResponseEntity>> + getIncompleteSpecialContractsWithoutMessage( + @PathVariable Long contractChatId, Authentication authentication) { + + try { + Long userId = getUserIdFromAuthentication(authentication); + List result = + contractChatService.getIncompleteSpecialContractsWithoutMessage( + contractChatId, userId); + + return ResponseEntity.ok(ApiResponse.success(result, "메시지가 없는 미완료 특약 목록 조회 성공")); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("INTERNAL_ERROR", "미완료 특약 목록 조회 중 오류가 발생했습니다.")); + } + } + @Override @PostMapping("/special-contract/{contractChatId}/ai") public ResponseEntity> sendAiMessage( @@ -703,4 +720,246 @@ public ResponseEntity> getFinalSpecial .body(ApiResponse.error("INTERNAL_ERROR", "최종 특약서 조회 중 오류가 발생했습니다.")); } } + + @Override + @PostMapping("/final-contract/{contractChatId}/modification-request") + public ResponseEntity> requestFinalContractModification( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationRequestDto requestDto, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + 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", "수정 요청 중 오류가 발생했습니다.")); + } + } + + @Override + @PostMapping("/final-contract/{contractChatId}/modification-response") + public ResponseEntity> respondToModificationRequest( + @PathVariable Long contractChatId, + @RequestBody FinalContractModificationResponseDto responseDto, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + 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", "응답 처리 중 오류가 발생했습니다.")); + } + } + + @Override + @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("/final-contract/{contractChatId}/deletion-request/{clauseOrder}") + 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 + @PostMapping("/final-contract/{contractChatId}/deletion-response") + public ResponseEntity>> respondToFinalContractDeletion( + @PathVariable Long contractChatId, + @RequestBody FinalContractDeletionResponseDto responseDto, + Authentication authentication) { + try { + Long userId = getUserIdFromAuthentication(authentication); + + Map result = + contractChatService.respondToFinalContractDeletionRequest( + 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", "응답 처리 중 오류가 발생했습니다.")); + } + } + + @Override + @PostMapping("/{contractChatId}/final-contract/accept-confirmation") + public ResponseEntity>> acceptFinalContractConfirmation( + @PathVariable Long contractChatId, + @RequestBody FinalContractDeletionResponseDto responseDto, + 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; + String message; + + if (responseDto.isAccepted()) { + result = + contractChatService.acceptFinalContractConfirmation(contractChatId, userId); + message = "최종 특약서가 확정되었습니다."; + } else { + contractChatService.rejectFinalContractConfirmation(contractChatId, userId); + result = + Map.of( + "message", "최종 특약 확정을 거절했습니다.", + "status", "REJECTED"); + message = "최종 특약 확정을 거절했습니다."; + } + + return ResponseEntity.ok(ApiResponse.success(result, message)); + } catch (Exception e) { + log.error("최종 특약 확정 응답 실패", e); + return ResponseEntity.badRequest() + .body(ApiResponse.error("최종 특약 확정 응답에 실패했습니다: " + e.getMessage())); + } + } + + @Override + @GetMapping("/{contractChatId}/status") + public ResponseEntity> getContractStatus( + @PathVariable Long contractChatId, Authentication authentication) { + Long userId = getUserIdFromAuthentication(authentication); + if (!contractChatService.isUserInContractChat(contractChatId, userId)) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("ACCESS_DENIED", "해당 계약 채팅방에 접근 권한이 없습니다.")); + } + + ContractChat contractChat = contractChatService.getContractChatInfo(contractChatId, userId); + if (contractChat == null) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("NOT_FOUND", "해당 계약 채팅방을 찾을 수 없습니다.")); + } + + String statusParam = contractChatService.getContractStatusParam(contractChatId, userId); + if (statusParam == null) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("INVALID_STATUS", "유효하지 않은 계약 상태입니다.")); + } + + return ResponseEntity.ok(ApiResponse.success(statusParam, "계약 상태 조회 성공")); + } } diff --git a/src/main/java/org/scoula/domain/chat/dto/FinalContractDeletionResponseDto.java b/src/main/java/org/scoula/domain/chat/dto/FinalContractDeletionResponseDto.java new file mode 100644 index 00000000..623dc36c --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/FinalContractDeletionResponseDto.java @@ -0,0 +1,13 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +public class FinalContractDeletionResponseDto { + private boolean accepted; +} 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..ddbae65d --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationRequestDto.java @@ -0,0 +1,14 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FinalContractModificationRequestDto { + 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..9859bce6 --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/FinalContractModificationResponseDto.java @@ -0,0 +1,12 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FinalContractModificationResponseDto { + 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..0d17ba90 --- /dev/null +++ b/src/main/java/org/scoula/domain/chat/dto/ModificationRequestData.java @@ -0,0 +1,15 @@ +package org.scoula.domain.chat.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ModificationRequestData { + private String newTitle; + private String newContent; + private Long requesterId; + private String createdAt; +} 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..c35a81e9 100644 --- a/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java +++ b/src/main/java/org/scoula/domain/chat/repository/SpecialContractMongoRepository.java @@ -7,9 +7,6 @@ import org.scoula.domain.chat.document.SpecialContractDocument; import org.scoula.domain.chat.document.SpecialContractFixDocument; import org.scoula.domain.chat.document.SpecialContractSelectionDocument; -import org.scoula.domain.chat.dto.SpecialContractUserViewDto; -import org.scoula.domain.chat.mapper.ContractChatMapper; -import org.scoula.domain.chat.vo.ContractChat; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; @@ -47,44 +44,17 @@ public Optional findByContractChatId(Long contractCh return Optional.ofNullable(result); } - /** contractChatId 존재 여부 확인 */ - public boolean existsByContractChatId(Long contractChatId) { - Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); - return mongoTemplate.exists(query, SpecialContractFixDocument.class); - } - /** 특약 문서 업데이트 */ public SpecialContractFixDocument updateSpecialContract(SpecialContractFixDocument document) { return mongoTemplate.save(document); } - /** 특정 라운드의 특약 문서들 조회 */ - public List findByRound(Long round) { - Query query = new Query(Criteria.where("round").is(round)); - return mongoTemplate.find(query, SpecialContractFixDocument.class); - } - /** 완료 여부로 특약 문서들 조회 */ public List findByIsPassed(Boolean isPassed) { Query query = new Query(Criteria.where("isPassed").is(isPassed)); return mongoTemplate.find(query, SpecialContractFixDocument.class); } - /** order 순으로 정렬하여 모든 특약 문서 조회 */ - public List findAllByOrderByOrderAsc() { - Query query = new Query(); - query.with( - org.springframework.data.domain.Sort.by( - org.springframework.data.domain.Sort.Direction.ASC, "order")); - return mongoTemplate.find(query, SpecialContractFixDocument.class); - } - - /** 특정 order의 특약 문서들 조회 */ - public List findByOrder(Long order) { - Query query = new Query(Criteria.where("order").is(order)); - return mongoTemplate.find(query, SpecialContractFixDocument.class); - } - public Optional findByContractChatIdAndOrder( Long contractChatId, Long order) { Query query = @@ -95,116 +65,6 @@ public Optional findByContractChatIdAndOrder( return Optional.ofNullable(result); } - public Optional findByContractChatIdAndOrderAndRound( - Long contractChatId, Long order, Long round) { - Query query = - new Query( - Criteria.where("contractChatId") - .is(contractChatId) - .and("order") - .is(order) - .and("round") - .is(round)); - SpecialContractFixDocument result = - mongoTemplate.findOne(query, SpecialContractFixDocument.class); - return Optional.ofNullable(result); - } - - /** 특약 문서 삭제 */ - public void deleteSpecialContract(SpecialContractFixDocument document) { - mongoTemplate.remove(document); - } - - /** contractChatId로 특약 문서 삭제 */ - public void deleteByContractChatId(Long contractChatId) { - Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); - mongoTemplate.remove(query, SpecialContractFixDocument.class); - } - - /** contractChatId로 SpecialContractDocument (원본 특약 문서) 조회 */ - public Optional findSpecialContractDocumentByContractChatId( - Long contractChatId) { - Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); - SpecialContractDocument result = - mongoTemplate.findOne(query, SpecialContractDocument.class); - return Optional.ofNullable(result); - } - - /** 사용자 역할별 특약 문서 조회 (ID 포함) */ - public SpecialContractUserViewDto getSpecialContractForUserWithIds( - Long contractChatId, Long userId, ContractChatMapper contractChatMapper) { - // 1. Raw Document로 조회 - Query query = new Query(Criteria.where("contractChatId").is(contractChatId)); - org.bson.Document rawDocument = - mongoTemplate.findOne(query, org.bson.Document.class, "SPECIAL_CONTRACT"); - - if (rawDocument == null) { - throw new IllegalArgumentException("해당 특약 문서를 찾을 수 없습니다: " + contractChatId); - } - - // 2. 사용자 역할 확인 - 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"; - - // 3. Raw Document에서 데이터 추출 - Long docContractChatId = rawDocument.getLong("contractChatId"); - ContractChat contractChats = contractChatMapper.findByContractChatId(contractChatId); - Long round = contractChats.getCurrentRound(); - Integer totalClauses = rawDocument.getInteger("totalClauses"); - - @SuppressWarnings("unchecked") - java.util.List clausesDocs = - (java.util.List) rawDocument.get("clauses"); - - java.util.List userClauses = - new java.util.ArrayList<>(); - - for (org.bson.Document clauseDoc : clausesDocs) { - Integer clauseId = clauseDoc.getInteger("_id"); // MongoDB의 _id 필드 - String title = clauseDoc.getString("title"); - String content = clauseDoc.getString("content"); - - org.bson.Document assessmentDoc = (org.bson.Document) clauseDoc.get("assessment"); - org.bson.Document userEvalDoc = - isOwner - ? (org.bson.Document) assessmentDoc.get("owner") - : (org.bson.Document) assessmentDoc.get("tenant"); - - String level = userEvalDoc.getString("level"); - String reason = userEvalDoc.getString("reason"); - - SpecialContractUserViewDto.ClauseUserView clauseView = - SpecialContractUserViewDto.ClauseUserView.builder() - .id(clauseId) - .title(title) - .content(content) - .level(level) - .reason(reason) - .build(); - - userClauses.add(clauseView); - } - - return SpecialContractUserViewDto.builder() - .contractChatId(docContractChatId) - .round(round) - .totalClauses(totalClauses) - .userRole(userRole) - .clauses(userClauses) - .build(); - } - public List findByContractChatIdAndIsPassed( Long contractChatId, Boolean isPassed) { Query query = @@ -216,6 +76,21 @@ public List findByContractChatIdAndIsPassed( return mongoTemplate.find(query, SpecialContractFixDocument.class); } + public List + findByContractChatIdAndIsPassedAndRecentDataMessagesEmpty( + Long contractChatId, Boolean isPassed) { + Criteria criteria = + Criteria.where("contractChatId") + .is(contractChatId) + .and("isPassed") + .is(isPassed) + .orOperator( + Criteria.where("recentData.messages").exists(false), + Criteria.where("recentData.messages").regex("^\\s*$")); + Query query = new Query(criteria); + return mongoTemplate.find(query, SpecialContractFixDocument.class); + } + public Optional findSpecialContractDocumentByContractChatIdAndRound( Long contractChatId, Long round) { Query query = 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 368f70ab..480f9869 100644 --- a/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java @@ -20,6 +20,7 @@ import org.scoula.domain.user.vo.User; import org.scoula.global.common.exception.BusinessException; import org.scoula.global.file.service.S3ServiceInterface; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -52,6 +53,12 @@ public class ChatServiceImpl implements ChatServiceInterface { private final ContractChatMapper contractChatMapper; private final RedisTemplate stringRedisTemplate; + @Value("${front.base.url}") + private String URL; + + private String PRECONTRACTURL = "/pre-contract/"; + private String BUYERURL = "/buyer?step=1"; + /** {@inheritDoc} */ @Override @Transactional @@ -100,7 +107,20 @@ public void handleChatMessage(ChatMessageRequestDto dto) { mongoRepository.saveMessage(dto.getChatRoomId(), message); - String preview = dto.getType().equals("TEXT") ? dto.getContent() : "[파일]"; + String preview; + switch (dto.getType()) { + case "TEXT": + preview = dto.getContent(); + break; + case "URLLINK": + preview = "[링크]"; + break; + case "FILE": + preview = "[파일]"; + break; + default: + preview = "[메시지]"; + } LocalDateTime now = LocalDateTime.now(); chatRoomMapper.updateLastMessage(dto.getChatRoomId(), preview, now); @@ -819,22 +839,15 @@ public Long acceptContractRequest(Long chatRoomId, Long userId) { .build(); 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"; + String contractChatUrl = URL + PRECONTRACTURL + (contractChatRoomId.toString()) + BUYERURL; ChatMessageRequestDto linkMessage = ChatMessageRequestDto.builder() .chatRoomId(chatRoomId) - .senderId(userId) + .senderId(originalChatRoom.getOwnerId()) .receiverId(originalChatRoom.getBuyerId()) .content(contractChatUrl) - .type("TEXT") + .type("URLLINK") .build(); handleChatMessage(linkMessage); 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 f12f059d..1969f57b 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; @@ -22,12 +21,14 @@ import org.scoula.global.common.exception.BusinessException; import org.scoula.global.common.exception.EntityNotFoundException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate; 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; @@ -40,14 +41,20 @@ public class ContractChatServiceImpl implements ContractChatServiceInterface { private final ChatRoomMapper chatRoomMapper; private final ContractChatMessageRepository contractChatMessageRepository; private final SimpMessagingTemplate messagingTemplate; - @Lazy private final ChatServiceInterface chatService; + 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; + @Value("${front.base.url}") + private String baseUrl; + + private String contractChatUrl = "/contract/"; + /** {@inheritDoc} */ @Override @Transactional @@ -633,10 +640,8 @@ public void createNextRoundSpecialContractDocument( Long newRound = currentRound + 1; log.info("새 라운드: {} → {}", currentRound, newRound); - // 이전 라운드에서 통과된 특약들도 찾아서 포함 List allPassedOrders = new ArrayList<>(passedOrders); - // 이미 완료된 특약들(isPassed=true)도 추가로 가져와서 포함 List completedContracts = specialContractMongoRepository.findByContractChatIdAndIsPassed( contractChatId, true); @@ -656,7 +661,6 @@ public void createNextRoundSpecialContractDocument( Long orderLong = Long.valueOf(order); if (allPassedOrders.contains(orderLong)) { - // 통과된 특약들을 복사 (이전 라운드에서 완료된 것들 포함) Optional clauseOpt = findBestClauseForOrder(contractChatId, orderLong); @@ -721,7 +725,6 @@ public void createNextRoundSpecialContractDocument( newClauses.add(emptyClause); log.info("거부된 특약 {}번 빈 껍데기 생성 완료", order); } else { - // 유지되는 특약들 latestDocument.getClauses().stream() .filter(clause -> clause.getOrder().equals(orderInteger)) .findFirst() @@ -796,10 +799,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 @@ -964,7 +965,6 @@ public Object submitUserSelection( return processRoundResults(contractChatId, document, currentStatus, isOwner); } - /** 현재 상태에 따른 선택 가능한 특약들 반환 */ private List getAvailableOrders( Long contractChatId, ContractChat.ContractStatus status) { if (status == ContractChat.ContractStatus.STEP0 @@ -980,7 +980,6 @@ private List getAvailableOrders( } } - /** 라운드별 결과 처리 (기존 로직 + 라운드 진행) */ @Transactional public Object processRoundResults( Long contractChatId, @@ -1017,6 +1016,8 @@ public Object processRoundResults( saveFinalSpecialContract(contractChatId); AiMessage(contractChatId, "모든 특약에 동의하셨습니다! 최종 특약서가 생성되었습니다."); + contractChatMapper.updateStatus( + contractChatId, ContractChat.ContractStatus.ROUND4); log.info("초안에서 최종 특약 저장 완료 - finalContractId: {}", finalContract.getId()); @@ -1059,24 +1060,32 @@ public Object processRoundResults( "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); + List remainingIncompleteContracts = + specialContractMongoRepository.findByContractChatIdAndIsPassed( + contractChatId, false); + if (remainingIncompleteContracts.isEmpty()) { + try { + FinalSpecialContractDocument finalContract = + saveFinalSpecialContract(contractChatId); + + AiMessageNext(contractChatId, "🎉 모든 특약 협상이 완료되었습니다! 최종 특약서가 생성되었습니다."); + contractChatMapper.updateStatus( + contractChatId, ContractChat.ContractStatus.ROUND4); + + return Map.of( + "message", + "모든 특약이 완료되었습니다!", + "completed", + true, + "finalContractId", + finalContract.getId(), + "totalFinalClauses", + finalContract.getTotalFinalClauses()); + } catch (Exception e) { + log.error("최종 특약 저장 실패", e); + return Map.of( + "message", "특약은 완료되었지만 최종 저장 중 오류가 발생했습니다.", "completed", true); + } } } @@ -1190,7 +1199,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++) { @@ -1557,7 +1566,17 @@ public List getIncompleteSpecialContractsByChat( contractChatId, false); } - /** 빈 ContentDataDto 생성 헬퍼 메서드 */ + @Override + public List getIncompleteSpecialContractsWithoutMessage( + Long contractChatId, Long userId) { + if (!isUserInContractChat(contractChatId, userId)) { + throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + + return specialContractMongoRepository + .findByContractChatIdAndIsPassedAndRecentDataMessagesEmpty(contractChatId, false); + } + private ContentDataDto createEmptyContentData() { return ContentDataDto.builder().title("").content("").messages("").build(); } @@ -1581,93 +1600,118 @@ private List findRejectedOrders( @Override @Transactional public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId) { - ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); - ContractChat.ContractStatus currentStatus = contractChat.getStatus(); - - boolean isThirdRoundComplete = (currentStatus == ContractChat.ContractStatus.ROUND3); + log.info("=== 최종 특약 저장 시작 ==="); + log.info("contractChatId: {}", contractChatId); - List finalClauses = new ArrayList<>(); + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + throw new IllegalArgumentException("계약 채팅방을 찾을 수 없습니다: " + contractChatId); + } - if (isThirdRoundComplete) { - log.info("=== 3회차 수정 완료 - 4라운드 데이터에서 최종 특약 생성 ==="); + SpecialContractDocument latestDocument = null; + Long latestRound = null; - Optional round4DocOpt = + for (Long round = 4L; round >= 1L; round--) { + Optional docOpt = 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()); - } - } + contractChatId, round); + + if (docOpt.isPresent()) { + latestDocument = docOpt.get(); + latestRound = round; + log.info("가장 최근 라운드 발견: {}", round); + break; } - } else { - log.info("=== 모든 특약 완료 - 완료된 특약들만 최종 저장 ==="); + } - List incompleteContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, false); + if (latestDocument == null) { + throw new IllegalStateException("특약 문서를 찾을 수 없습니다: " + contractChatId); + } - if (!incompleteContracts.isEmpty()) { - throw new IllegalStateException( - "아직 완료되지 않은 특약이 " + incompleteContracts.size() + "개 있습니다."); - } + List finalClauses = new ArrayList<>(); - List completedContracts = - specialContractMongoRepository.findByContractChatIdAndIsPassed( - contractChatId, true); + for (SpecialContractDocument.Clause clause : latestDocument.getClauses()) { + if (clause.getOrder() != null) { + String title = clause.getTitle(); + String content = clause.getContent(); - if (completedContracts.isEmpty()) { - throw new IllegalStateException("완료된 특약이 없습니다."); - } + if (title != null + && !title.trim().isEmpty() + && content != null + && !content.trim().isEmpty()) { - for (SpecialContractFixDocument completedContract : completedContracts) { - Long order = completedContract.getOrder(); + FinalSpecialContractDocument.FinalClause finalClause = + FinalSpecialContractDocument.FinalClause.builder() + .order(clause.getOrder()) + .title(title.trim()) + .content(content.trim()) + .build(); - Optional latestRoundDoc = - findLatestRoundForOrder(contractChatId, order); + 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 (latestRoundDoc.isPresent()) { - SpecialContractDocument doc = latestRoundDoc.get(); + 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()) { - 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()) + .order(prevClause.getOrder()) + .title(prevTitle.trim()) + .content(prevContent.trim()) .build(); finalClauses.add(finalClause); log.info( - "특약 {}번 최종 저장 완료 - sourceRound: {}", - order, - doc.getRound()); - }); + "특약 {}번 저장 완료 (이전 라운드 {}): {}", + 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) @@ -1678,10 +1722,9 @@ public FinalSpecialContractDocument saveFinalSpecialContract(Long contractChatId FinalSpecialContractDocument savedDocument = specialContractMongoRepository.saveFinalSpecialContract(finalDocument); - log.info( - "최종 특약 저장 완료 - 총 {}개 조항 (방식: {})", - finalClauses.size(), - isThirdRoundComplete ? "3회차 완료" : "모든 특약 완료"); + log.info("=== 최종 특약 저장 완료 ==="); + log.info("저장된 문서 ID: {}", savedDocument.getId()); + log.info("총 특약 개수: {}", savedDocument.getTotalFinalClauses()); return savedDocument; } @@ -1779,7 +1822,6 @@ public void checkAndIncrementRoundIfComplete(Long contractChatId) { } } - /** 최종 라운드(4차) 완료 체크 및 자동 완료 처리 */ @Transactional public void checkFinalRoundCompletion(Long contractChatId) { log.info("=== 최종 라운드(4차) 완료 체크 시작 ==="); @@ -1839,6 +1881,7 @@ public void checkFinalRoundCompletion(Long contractChatId) { + "총 " + finalContract.getTotalFinalClauses() + "개의 특약이 확정되었습니다."); + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.ROUND4); log.info( "최종 특약 자동 저장 완료 - finalContractId: {}, 총 {}개 조항", @@ -1847,9 +1890,6 @@ public void checkFinalRoundCompletion(Long contractChatId) { } catch (Exception e) { log.error("최종 특약 자동 저장 실패", e); - AiMessage( - contractChatId, - "모든 특약 협상이 완료되었지만 최종 특약서 생성 중 오류가 발생했습니다. " + "관리자에게 문의해주세요."); } } else { log.info("아직 4차 라운드의 모든 특약이 작성되지 않음"); @@ -1912,6 +1952,35 @@ private Long getNextRoundNumber(ContractChat.ContractStatus status) { } } + public String getContractChatStatus(ContractChat.ContractStatus status) { + switch (status) { + case STEP0: + return "?step=1"; + case STEP1: + return "?step=2"; + case STEP2: + return "?step=3"; + case ROUND0: + return "?step=3&round=0"; + case ROUND1: + return "?step=3&round=1"; + case ROUND2: + return "?step=3&round=2"; + case ROUND3: + return "?step=3&round=3"; + case ROUND4: + return "?step=3&round=4"; + default: + return null; + } + } + + @Override + public String getContractStatusParam(Long contractChatId, Long userId) { + ContractChat contractChat = getContractChatInfo(contractChatId, userId); + return getContractChatStatus(contractChat.getStatus()); + } + private String getRoundIncrementMessage(ContractChat.ContractStatus status) { switch (status) { case ROUND1: @@ -1924,4 +1993,567 @@ private String getRoundIncrementMessage(ContractChat.ContractStatus status) { return "새로운 협상 라운드가 시작됩니다."; } } + + @Override + @Transactional + public Map respondToFinalContractDeletionRequest( + Long contractChatId, Long buyerId, FinalContractDeletionResponseDto 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:deletion:" + contractChatId + ":" + contractChat.getOwnerId(); + String clauseOrderStr = stringRedisTemplate.opsForValue().get(redisKey); + + if (clauseOrderStr == null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "삭제 요청이 존재하지 않습니다."); + } + + Integer clauseOrder = Integer.parseInt(clauseOrderStr); + + Map result = new HashMap<>(); + String resultMessage; + + if (responseDto.isAccepted()) { + // 삭제 수락 로직 + 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); + + resultMessage = String.format("임차인이 특약 %d번 삭제 요청을 수락했습니다. 특약이 삭제되었습니다.", clauseOrder); + + result.put("message", "특약이 삭제되었습니다."); + result.put("deletedClauseOrder", clauseOrder); + result.put("finalContractId", finalContract.getId()); + result.put("remainingClauses", finalContract.getTotalFinalClauses()); + + log.info("특약 {}번 삭제 완료 - contractChatId: {}", clauseOrder, contractChatId); + + } else { + // 삭제 거절 + resultMessage = String.format("임차인이 특약 %d번 삭제 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); + + result.put("message", "삭제 요청을 거절했습니다."); + result.put("clauseOrder", clauseOrder); + + log.info("특약 {}번 삭제 거절 완료 - contractChatId: {}", clauseOrder, contractChatId); + } + + stringRedisTemplate.delete(redisKey); + AiMessage(contractChatId, resultMessage); + + return result; + } + + @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 + ":" + ownerId; + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); + } + + ModificationRequestData requestData = + ModificationRequestData.builder() + .newTitle(requestDto.getNewTitle()) + .newContent(requestDto.getNewContent()) + .requesterId(ownerId) + .createdAt(LocalDateTime.now().toString()) + .build(); + + try { + String jsonData = objectMapper.writeValueAsString(requestData); + String valueData = + String.format( + "{\"clauseOrder\":%d,\"requestData\":%s}", + requestDto.getClauseOrder(), jsonData); + stringRedisTemplate.opsForValue().set(redisKey, valueData, 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 + ":" + contractChat.getOwnerId(); + String valueDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (valueDataJson == null) { + throw new IllegalArgumentException("대기중인 수정 요청이 없습니다."); + } + + try { + // JSON에서 clauseOrder와 requestData 추출 + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(valueDataJson); + Integer clauseOrder = rootNode.get("clauseOrder").asInt(); + String requestDataJson = rootNode.get("requestData").toString(); + + 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(clauseOrder)) { + 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번 수정 요청을 수락했습니다. 특약이 변경되었습니다.", clauseOrder); + log.info("수정 수락 - 최종 특약서 업데이트 완료"); + + } else { + resultMessage = + String.format("임차인이 특약 %d번 수정 요청을 거절했습니다. 기존 특약이 유지됩니다.", clauseOrder); + 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) { + // 기존 방식 대신 임대인의 요청을 찾도록 수정 + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat == null) { + return null; + } + + String redisKey = + "final-contract:modification:" + contractChatId + ":" + contractChat.getOwnerId(); + String valueDataJson = stringRedisTemplate.opsForValue().get(redisKey); + + if (valueDataJson == null) { + return null; + } + + try { + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(valueDataJson); + Integer storedClauseOrder = rootNode.get("clauseOrder").asInt(); + + // 요청한 clauseOrder와 저장된 clauseOrder가 일치하는지 확인 + if (!clauseOrder.equals(storedClauseOrder)) { + return null; + } + + String requestDataJson = rootNode.get("requestData").toString(); + 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("최종 특약서가 생성되지 않았습니다."); + } + + AiMessageBtn(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); + AiMessageNext( + contractChatId, + "다음은 마지막 4단계: ‘적법성 검토' 단계입니다.\n" + + "\n" + + "해당 계약 내용을 기준으로 법률적 적합성을 분석할게요. 잠시만 기다려주세요."); + + 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 + ":" + ownerId; + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new BusinessException( + ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 삭제 요청이 진행 중입니다."); + } + + stringRedisTemplate + .opsForValue() + .set(redisKey, clauseOrder.toString(), Duration.ofHours(24)); + + String notificationMessage = String.format("임대인이 특약 %d번 삭제를 요청했습니다.", clauseOrder); + + AiMessageBtn(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); + } + + public String getContractChatRoomUrl(Long chatRoomId) { + ChatRoom chatRoom = chatRoomMapper.findById(chatRoomId); + if (chatRoom == null) { + log.error("채팅방을 찾을 수 없음: {}", chatRoomId); + throw new BusinessException(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + ContractChat contractChatId = + contractChatMapper.findByUserAndHome( + chatRoom.getOwnerId(), chatRoom.getBuyerId(), chatRoom.getHomeId()); + if (contractChatId == null) { + log.error("채팅방을 찾을 수 없음: {}", chatRoomId); + throw new BusinessException(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + Long contractChatRoomId = contractChatId.getContractChatId(); + String param = getContractChatStatus(contractChatId.getStatus()); + + return baseUrl + contractChatUrl + contractChatRoomId.toString() + param; + } } 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..8824ea28 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; @@ -245,6 +244,9 @@ public interface ContractChatServiceInterface { List getIncompleteSpecialContractsByChat( Long contractChatId, Long userId); + List getIncompleteSpecialContractsWithoutMessage( + Long contractChatId, Long userId); + List proceedAllIncompleteToNextRound(Long contractChatId); void createNextRoundSpecialContractDocument( @@ -259,4 +261,44 @@ void createNextRoundSpecialContractDocument( void AiMessage(Long contractChatId, String content); void AiMessageBtn(Long contractChatId, String content); + + void AiMessageNext(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); + + String getContractStatusParam(Long contractChatId, Long userId); + + /** 임차인이 최종 특약 삭제 거절 */ + void rejectFinalContractDeletion(Long contractChatId, Long buyerId, Integer clauseOrder); + + Map respondToFinalContractDeletionRequest( + Long contractChatId, Long buyerId, FinalContractDeletionResponseDto responseDto); + + String getContractChatRoomUrl(Long chatRoomId); + + String getContractChatStatus(ContractChat.ContractStatus status); } 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 3ae0b67e..756ed828 100644 --- a/src/main/java/org/scoula/domain/chat/vo/ContractChat.java +++ b/src/main/java/org/scoula/domain/chat/vo/ContractChat.java @@ -29,6 +29,7 @@ public enum ContractStatus { ROUND1, ROUND2, ROUND3, + ROUND4, STEP4 } @@ -45,6 +46,8 @@ public Long getCurrentRound() { return 3L; case ROUND3: return 4L; + case ROUND4: + return 5L; default: return 1L; } @@ -56,6 +59,7 @@ public boolean isInRound() { && (status == ContractStatus.ROUND0 || status == ContractStatus.ROUND1 || status == ContractStatus.ROUND2 - || status == ContractStatus.ROUND3); + || status == ContractStatus.ROUND3 + || status == ContractStatus.ROUND4); } } 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 71fcb65f..a72f2d1a 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java @@ -82,7 +82,7 @@ public Void standByContract(Long contractChatId, Long userId) { @Override public Void saveContractMongo(Long contractChatId, Long userId) { // userId 검증 - validateUserId(contractChatId, userId); + validateIsOwner(contractChatId, userId); // 이미 생성된 계약 문서가 있으면 저장 대신 안내 메시지 전송 후 종료 ContractMongoDocument existing = repository.getContract(contractChatId); @@ -473,6 +473,14 @@ public void validateUserId(Long contractChatId, Long userId) { } } + 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); + } + } + private static String formatWonShort(int amount) { if (amount == 0) return "0원"; long eok = amount / 100_000_000; // 억 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 e13f1d76..75d1f395 100644 --- a/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java +++ b/src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java @@ -17,6 +17,8 @@ public interface TenantPreContractMapper { Optional selectContractBuyerId(@Param("contractChatId") Long contractChatId); + Optional selectContractOwnerId(@Param("contractChatId") Long contractChatId); + Optional selectBuyerId(@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 5545014e..2e2ce728 100644 --- a/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java @@ -7,6 +7,14 @@ import java.util.stream.Collectors; import org.scoula.domain.chat.document.SpecialContractDocument; +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.contract.service.ContractService; import org.scoula.domain.precontract.document.ContractDocumentMongoDocument; import org.scoula.domain.precontract.document.OwnerMongoDocument; import org.scoula.domain.precontract.dto.ai.ClauseRecommendRequestDto; @@ -52,6 +60,11 @@ public class OwnerPreContractServiceImpl implements OwnerPreContractService { private final MongoTemplate mongoTemplate; private final ObjectMapper objectMapper; private final AesCryptoUtil aesCryptoUtil; + private final ChatServiceInterface chatService; + private final ContractChatServiceInterface contractChatService; + private final ContractChatMapper contractChatMapper; + private final ContractService contractService; + private final ChatRoomMapper chatRoomMapper; @Override public Void requireVerification( @@ -411,6 +424,25 @@ public Void saveMongoDB(Long contractChatId, Long userId) { saveOwnerDocument(dto); processAiClauseRecommendation(contractChatId, userId, dto); + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + ChatRoom chatRoom = + chatRoomMapper.findByUserAndHome( + contractChat.getOwnerId(), + contractChat.getBuyerId(), + contractChat.getHomeId()); + contractChatService.getContractChatStatus(contractChat.getStatus()); + ChatMessageRequestDto linkMessages = + ChatMessageRequestDto.builder() + .chatRoomId(chatRoom.getChatRoomId()) + .senderId(contractChat.getBuyerId()) + .receiverId(contractChat.getOwnerId()) + .content("계약 채팅방 URL") + .type("URLLINK") + .build(); + chatService.handleChatMessage(linkMessages); + contractChatService.AiMessage( + contractChatId, "\uD83D\uDC4B 임대인께서 입장하셨습니다! \n" + "지금부터 계약을 진행하겠습니다."); + contractService.saveContractMongo(contractChatId, userId); return null; } @@ -571,19 +603,17 @@ private ClauseRecommendRequestDto buildClauseRecommendRequest( ClauseRecommendRequestDto.OwnerData ownerRequestData = buildOwnerData(ownerData); ClauseRecommendRequestDto.TenantData tenantData = buildTenantData(ownerData); - return null; + return ClauseRecommendRequestDto.builder() + .ocrData(ocrData) + .ownerData(ownerRequestData) + .tenantData(tenantData) + .build(); } private ClauseRecommendRequestDto.OcrData buildOcrData( ContractDocumentMongoDocument contractDocument) { if (contractDocument == null) { - return ClauseRecommendRequestDto.OcrData.builder() - .extractedAt(null) - .fileName(null) - .rawText(null) - .source(null) - .specialTerms(null) - .build(); + return null; } return ClauseRecommendRequestDto.OcrData.builder() diff --git a/src/main/java/org/scoula/domain/precontract/service/PreContractServiceImpl.java b/src/main/java/org/scoula/domain/precontract/service/PreContractServiceImpl.java index acb06ed0..39b2e3fb 100644 --- a/src/main/java/org/scoula/domain/precontract/service/PreContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/precontract/service/PreContractServiceImpl.java @@ -2,6 +2,11 @@ import java.util.Optional; +import org.scoula.domain.chat.dto.ChatMessageRequestDto; +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.ContractChat; import org.scoula.domain.precontract.dto.tenant.*; import org.scoula.domain.precontract.enums.RentType; import org.scoula.domain.precontract.exception.PreContractErrorCode; @@ -11,6 +16,7 @@ import org.scoula.domain.precontract.vo.TenantPreContractCheckVO; import org.scoula.domain.precontract.vo.TenantWolseInfoVO; import org.scoula.global.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +31,15 @@ public class PreContractServiceImpl implements PreContractService { private final TenantPreContractMapper tenantMapper; private final TenantMongoRepository mongoRepository; + private final ChatServiceInterface chatService; + private final ContractChatMapper contractChatMapper; + private final ContractChatServiceInterface contractChatService; + + @Value("${front.base.url}") + private String URL; + + private String precontractUrl = "/pre-contract/"; + private String ownerUrl = "/owner?step=1"; // =============== 사기 위험도 확인 & 기본 세팅 ================== @@ -343,6 +358,22 @@ public Void saveMongoDB(Long contractChatId, Long userId) { throw new BusinessException(PreContractErrorCode.TENANT_INSERT, e); } + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + + String contractChatUrls = URL + precontractUrl + (contractChatId.toString()) + ownerUrl; + + ChatMessageRequestDto linkMessages = + ChatMessageRequestDto.builder() + .chatRoomId(contractChatId) + .senderId(contractChat.getBuyerId()) + .receiverId(contractChat.getOwnerId()) + .content(contractChatUrls) + .type("URLLINK") + .build(); + contractChatService.AiMessage(contractChatId, "안녕하세요!\n" + "임대인이 입장하면 바로 계약서 작성을 시작할게요."); + contractChatService.AiMessageBtn(contractChatId, "기다리는 동안 \n" + "어려운 법률 용어와 법률 팁을 알아볼까요?"); + chatService.handleChatMessage(linkMessages); + return null; } } 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 38ed3a74..12c94950 100644 --- a/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml +++ b/src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml @@ -11,6 +11,12 @@ WHERE contract_chat_id = #{contractChatId} + +