diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0a14720a..e9aa12a0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -9,7 +9,7 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}-backend + IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: @@ -64,8 +64,8 @@ jobs: file: ./Dockerfile push: true tags: | - ${{ env.REGISTRY }}/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:${{ github.ref_name }} - ${{ env.REGISTRY }}/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:latest + ${{ env.REGISTRY }}/itzeep/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:${{ github.ref_name }} + ${{ env.REGISTRY }}/itzeep/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:latest deploy: needs: build-and-push @@ -98,7 +98,7 @@ jobs: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin # Pull new image - docker pull ${{ env.REGISTRY }}/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:${{ github.ref_name }} + docker pull ghcr.io/itzeep/backend:latest # Navigate to project directory cd $DATA_PATH @@ -110,7 +110,7 @@ jobs: fi # Update docker-compose.yml to use GHCR image - sed -i "s|build:.*|image: ${{ env.REGISTRY }}/${{ steps.string.outputs.IMAGE_NAME_LOWER }}:${{ github.ref_name }}|g" docker-compose.yml + sed -i "s|build:.*|image: ${{ secrets.IMAGE_NAME}}|g" docker-compose.yml sed -i "s|context:.*||g" docker-compose.yml sed -i "s|dockerfile:.*||g" docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 19045258..a0051253 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,14 @@ RUN cd webapps && \ chown -R tomcat:tomcat ROOT && \ chmod -R 755 ROOT +# Copy Firebase service account file to the correct location +# Firebase Admin SDK looks for this file in the classpath +COPY config-submodule/firebase-service-account.json /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/firebase-service-account.json + +# Set proper permissions for Firebase service account file +RUN chown tomcat:tomcat /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/firebase-service-account.json && \ + chmod 644 /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/firebase-service-account.json + # Create logs directory and set proper permissions RUN mkdir -p /usr/local/tomcat/logs && \ chmod 777 /usr/local/tomcat/logs && \ diff --git a/config-submodule b/config-submodule index b330f7d8..69cf573f 160000 --- a/config-submodule +++ b/config-submodule @@ -1 +1 @@ -Subproject commit b330f7d863973acf8c473b0fe532866201a335d9 +Subproject commit 69cf573fb27f9be28c7c5987deda8b3e212a4f2e 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 d158ac0a..54bef271 100644 --- a/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java +++ b/src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java @@ -2911,98 +2911,100 @@ public Map acceptFinalContract( return Map.of("accepted", isAccepted); } - 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); - AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!"); - } else { - AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다."); - } - - return Map.of("accepted", isAccepted); - } + // 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); + // AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!"); + // } else { + // AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다."); + // } + // + // return Map.of("accepted", isAccepted); + // } } 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 42a91c76..0d0a0bcd 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractController.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractController.java @@ -1,7 +1,10 @@ package org.scoula.domain.contract.controller; +import java.util.List; import java.util.Map; +import javax.servlet.http.HttpServletResponse; + import org.scoula.domain.chat.dto.FinalContractDeletionResponseDto; import org.scoula.domain.contract.dto.*; import org.scoula.global.auth.dto.CustomUserDetails; @@ -11,6 +14,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -128,4 +133,74 @@ ResponseEntity>> acceptFinalContract( @PathVariable Long contractChatId, @RequestBody FinalContractDeletionResponseDto responseDto, Authentication authentication); + + // ================= + + // 내보내기 + @ApiOperation( + value = "[내보내기] 0 계약서 내보내기 시작", + notes = "계약서 내보내기 프로세스를 시작합니다. AI 서버에서 초기 PDF를 생성합니다.") + ResponseEntity startContractExport( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @ApiOperation(value = "[내보내기] 사용자 역할 확인", notes = "계약서에서 사용자가 owner인지 buyer인지 확인") + ResponseEntity> getUserRole( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @ApiOperation(value = "[내보내기] 1 최종 계약서 PDF로 만들기 -> AI", notes = "최종 계약서에 들어갈 항목들로 최종 계약서 만들기 ") + ResponseEntity> finalContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @ApiOperation(value = "[내보내기] 2 받아온 전자서명 파일 암호화 후 S3에 저장", notes = "전자서명 png를 s3에 저장합니다.") + ResponseEntity> saveSignature( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestPart("dto") MultipartFile dtoFile, // JSON 파트 + @RequestPart("imgFiles") List imgFiles) + throws Exception; + + @ApiOperation(value = "[내보내기] 3 최종 계약서 PDF S3에 저장", notes = "사용자에게 암호를 받아 암호화 후 S3에 저장하기") + ResponseEntity> saveFinalContract( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody ContractPasswordDTO dto); + + @ApiOperation(value = "[내보내기] 4 최종 계약서 PDF를 보여줍니다.", notes = "최종 계약서 PDF를 보여줍니다.") + ResponseEntity> selectContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody ContractPasswordDTO dto) + throws Exception; + + // @ApiOperation(value = "최종 계약서 PDF 파일 받아와서 암호화 후 S3에 저장", notes = "최종 계약서 PDF를 암호화하여 S3에 + // 저장합니다.") + // ResponseEntity> saveContractPDF( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails, + // @RequestBody FinalContractDTO dto); + // + // @ApiOperation(value = "전자서명 다운로드", notes = "전자서명을 다운로드해서 복호화해서 프론트에 전송합니다.") + // ResponseEntity> selectSignaturePDF( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails, + // HttpServletResponse response); + + // 패스베리어블을 뭘로 받아올지 얘기해보기 : contract_id + @ApiOperation(value = "[내보내기] 5 계약서 PDF 파일 다운로드", notes = "계약서 PDF를 S3에서 꺼내 보내준다") + ResponseEntity> selectContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse response, + @RequestBody FindContractDTO dto) + throws Exception; + + @ApiOperation(value = "[내보내기] 6 최종 계약서 PDF를 이메일로 전송", notes = "최종 계약서 PDF를 이메일로 전송합니다.") + ResponseEntity> sendContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody FindContractDTO dto) + throws Exception; } 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 25842dca..23778e67 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java @@ -1,12 +1,16 @@ package org.scoula.domain.contract.controller; +import java.util.List; import java.util.Map; import java.util.Optional; +import javax.servlet.http.HttpServletResponse; + 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.ContractExportSyncService; import org.scoula.domain.contract.service.ContractFixServiceInterface; import org.scoula.domain.contract.service.ContractService; import org.scoula.domain.user.service.UserServiceInterface; @@ -14,11 +18,17 @@ 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.ContentDisposition; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -32,6 +42,7 @@ public class ContractControllerImpl implements ContractController { private final ContractFixServiceInterface contractFixService; private final UserServiceInterface userService; private final ContractService service; + private final ContractExportSyncService exportSyncService; private final ContractChatServiceInterface contractChatService; private Long getUserIdFromAuthentication(Authentication authentication) { @@ -252,4 +263,341 @@ public ResponseEntity>> acceptFinalContract( .body(ApiResponse.error("서버 오류가 발생했습니다.")); } } + + // ======================================== + + // @Override + // @PostMapping("/final_contract") + // public ResponseEntity> finalContractInit( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails) { + // return ResponseEntity.ok( + // ApiResponse.success( + // service.finalContractInit(contractChatId, + // userDetails.getUserId()))); + // } + + @Override + @PostMapping("/start-export") + public ResponseEntity startContractExport( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + byte[] pdfBytes = service.startContractExport(contractChatId, userDetails.getUserId()); + + if (pdfBytes == null || pdfBytes.length == 0) { + return ResponseEntity.noContent().build(); + } + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header("Content-Disposition", "inline; filename=\"contract.pdf\"") + .contentLength(pdfBytes.length) + .body(pdfBytes); + } + + @GetMapping("/preview") + public ResponseEntity> getPreviewPdf( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + try { + // 권한 확인: 해당 계약서의 임차인/임대인인지 확인 + String userRole = + exportSyncService.getUserRole(contractChatId, userDetails.getUserId()); + if (!"owner".equals(userRole) && !"buyer".equals(userRole)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("해당 계약서에 접근할 권한이 없습니다")); + } + + // PDF 생성 + byte[] pdfBytes = service.startContractExport(contractChatId, userDetails.getUserId()); + + if (pdfBytes == null || pdfBytes.length == 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("PDF 생성에 실패했습니다")); + } + + // S3에 임시 파일로 업로드하고 전체 URL 반환 + String fileName = + "contract_preview_" + + contractChatId + + "_" + + System.currentTimeMillis() + + ".pdf"; + String tempUrl = exportSyncService.uploadTempPdf(pdfBytes, fileName); + + log.info( + "PDF preview S3 upload successful - user: {}, role: {}, contract: {}, URL: {}", + userDetails.getUserId(), + userRole, + contractChatId, + tempUrl); + + return ResponseEntity.ok(ApiResponse.success(tempUrl)); + + } catch (Exception e) { + log.error("PDF 미리보기 생성 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("PDF 미리보기 생성에 실패했습니다: " + e.getMessage())); + } + } + + @GetMapping("/preview-url") + public ResponseEntity> createTempPdfUrl( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + try { + // 권한 확인: 해당 계약서의 임차인/임대인인지 확인 + String userRole = + exportSyncService.getUserRole(contractChatId, userDetails.getUserId()); + if (!"owner".equals(userRole) && !"buyer".equals(userRole)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("해당 계약서에 접근할 권한이 없습니다")); + } + + // PDF 생성 + byte[] pdfBytes = service.startContractExport(contractChatId, userDetails.getUserId()); + + if (pdfBytes == null || pdfBytes.length == 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("PDF 생성에 실패했습니다")); + } + + // 임시 파일명 생성 (계약서ID + 타임스탬프) + String fileName = + String.format("contract_%d_%d.pdf", contractChatId, System.currentTimeMillis()); + + // S3에 임시 업로드 (1시간 후 자동 삭제 설정) + String tempUrl = exportSyncService.uploadTempPdf(pdfBytes, fileName); + + log.info( + "Temporary PDF URL created for user {} (role: {}) for contract {}", + userDetails.getUserId(), + userRole, + contractChatId); + + return ResponseEntity.ok(ApiResponse.success(tempUrl)); + + } catch (Exception e) { + log.error("임시 PDF URL 생성 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("임시 URL 생성에 실패했습니다")); + } + } + + @GetMapping("/temp-pdf/{fileName}") + public ResponseEntity getTempPdf( + @PathVariable String fileName, @AuthenticationPrincipal CustomUserDetails userDetails) { + + try { + // 권한 확인: 해당 파일에 접근할 수 있는지 확인 + if (!exportSyncService.canAccessTempPdf(fileName, userDetails.getUserId())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("해당 파일에 접근할 권한이 없습니다")); + } + + // Redis에서 임시 URL 정보 조회 + String tempUrl = exportSyncService.getTempPdfUrl(fileName); + + if (tempUrl == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("파일을 찾을 수 없거나 만료되었습니다")); + } + + // S3 URL로 리다이렉트 + return ResponseEntity.status(HttpStatus.FOUND).header("Location", tempUrl).build(); + + } catch (Exception e) { + log.error("임시 PDF 접근 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("파일 접근에 실패했습니다")); + } + } + + @Override + @GetMapping("/export/role") + public ResponseEntity> getUserRole( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String role = exportSyncService.getUserRole(contractChatId, userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.success(role)); + } + + @Override + @GetMapping("/final_contract") + public ResponseEntity> finalContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok( + ApiResponse.success( + service.finalContractPDF(contractChatId, userDetails.getUserId()))); + } + + @PostMapping("/export/signature-status") + public ResponseEntity> updateSignatureStatus( + @PathVariable("contractChatId") Long contractChatId, + @RequestBody SignatureSubmitDTO signatureData, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + log.info( + "HTTP API: 서명 상태 업데이트 요청 - contractChatId: {}, userRole: {}", + contractChatId, + signatureData.getUserRole()); + + try { + ContractExportStatusDTO updatedStatus = + exportSyncService.updateSignature(contractChatId, signatureData); + return ResponseEntity.ok(ApiResponse.success(updatedStatus)); + } catch (Exception e) { + log.error("서명 상태 업데이트 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("서명 상태 업데이트에 실패했습니다")); + } + } + + @GetMapping("/export/status") + public ResponseEntity> getExportStatus( + @PathVariable("contractChatId") Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + try { + ContractExportStatusDTO status = exportSyncService.getExportStatus(contractChatId); + return ResponseEntity.ok(ApiResponse.success(status)); + } catch (Exception e) { + log.error("내보내기 상태 조회 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("상태 조회에 실패했습니다")); + } + } + + @Override + @PostMapping("/signature/tax") + public ResponseEntity> saveSignature( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestPart("dto") MultipartFile dtoFile, + @RequestPart("imgFiles") List imgFiles) + throws Exception { + if (imgFiles == null || imgFiles.isEmpty()) { + throw new IllegalArgumentException("서명 이미지가 비어 있습니다."); + } + + // MultipartFile에서 문자열 추출 + String dtoText; + try { + dtoText = new String(dtoFile.getBytes(), "UTF-8"); + } catch (Exception e) { + log.error("DTO 파일 읽기 실패: {}", e.getMessage()); + throw new IllegalArgumentException("DTO 파일을 읽을 수 없습니다."); + } + + // 문자열 -> DTO (JSON 파싱) + SaveSignatureDTO dto; + try { + dto = new ObjectMapper().readValue(dtoText, SaveSignatureDTO.class); + } catch (Exception e) { + log.error("DTO 파싱 실패: {}", e.getMessage()); + throw new IllegalArgumentException("잘못된 DTO 형식입니다."); + } + + return ResponseEntity.ok( + ApiResponse.success( + service.saveSignature( + contractChatId, userDetails.getUserId(), dto, imgFiles))); + } + + @Override + @PostMapping("/finalContract/p") + public ResponseEntity> saveFinalContract( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody ContractPasswordDTO dto) { + return ResponseEntity.ok( + ApiResponse.success( + service.saveFinalContract(contractChatId, userDetails.getUserId(), dto))); + } + + @Override + @GetMapping("/finalContract") + public ResponseEntity> selectContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetail, + @RequestBody ContractPasswordDTO dto) { + return ResponseEntity.ok( + ApiResponse.success( + service.selectContractPDF(contractChatId, userDetail.getUserId(), dto))); + } + + // @Override + // @PostMapping("/pdf") + // public ResponseEntity> saveContractPDF( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails, + // @RequestBody FinalContractDTO dto) { + // return ResponseEntity.ok( + // ApiResponse.success( + // service.saveContractPDF(contractChatId, userDetails.getUserId(), + // dto))); + // } + // + // @Override + // @GetMapping("/signature") + // public ResponseEntity> selectSignaturePDF( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails, + // HttpServletResponse response) { + // return ResponseEntity.ok( + // ApiResponse.success( + // service.selectSignaturePDF( + // contractChatId, userDetails.getUserId(), response))); + // } + + // 서명된 PDF 다운로드 (역할에 따른 암호화) + @PostMapping("/export/download-pdf") + public ResponseEntity downloadSignedPdf( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + try { + byte[] pdfData = + exportSyncService.getSignedPdfWithPassword( + contractChatId, userDetails.getUserId()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDisposition( + ContentDisposition.attachment() + .filename("contract_" + contractChatId + ".pdf") + .build()); + + return ResponseEntity.ok().headers(headers).body(pdfData); + } catch (Exception e) { + log.error("Failed to download signed PDF", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Override + @PostMapping("/pdf") + public ResponseEntity> selectContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse response, + @RequestBody FindContractDTO dto) + throws Exception { + return ResponseEntity.ok( + ApiResponse.success( + service.selectContractPDF( + contractChatId, userDetails.getUserId(), response, dto))); + } + + @Override + @PostMapping("/email") + public ResponseEntity> sendContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody FindContractDTO dto) + throws Exception { + return ResponseEntity.ok( + ApiResponse.success( + service.sendContractPDF(contractChatId, userDetails.getUserId(), dto))); + } } diff --git a/src/main/java/org/scoula/domain/contract/controller/RedisCleanupController.java b/src/main/java/org/scoula/domain/contract/controller/RedisCleanupController.java new file mode 100644 index 00000000..28db341a --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/controller/RedisCleanupController.java @@ -0,0 +1,71 @@ +package org.scoula.domain.contract.controller; + +import java.util.Set; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +/** Redis 데이터 정리용 임시 컨트롤러 개발 환경에서만 사용! 운영 환경에서는 제거해야 함 */ +@RestController +@RequestMapping("/api/admin/redis") +@RequiredArgsConstructor +@Log4j2 +public class RedisCleanupController { + + private final RedisTemplate redisTemplate; + + /** 특정 계약의 export 상태 삭제 */ + @DeleteMapping("/contract/{contractChatId}") + public String deleteContractExportStatus(@PathVariable Long contractChatId) { + String key = "contract:export:status:" + contractChatId; + Boolean deleted = redisTemplate.delete(key); + + // 암호 키도 삭제 + String ownerPasswordKey = "contract:export:password:" + contractChatId + ":owner"; + String buyerPasswordKey = "contract:export:password:" + contractChatId + ":buyer"; + redisTemplate.delete(ownerPasswordKey); + redisTemplate.delete(buyerPasswordKey); + + log.info("Deleted Redis keys for contract {}", contractChatId); + return "Deleted: " + deleted; + } + + /** 모든 contract export 관련 키 조회 */ + @GetMapping("/contract/export/keys") + public Set getContractExportKeys() { + Set keys = redisTemplate.keys("contract:export:*"); + log.info("Found {} export keys", keys != null ? keys.size() : 0); + return keys; + } + + /** 모든 contract export 관련 데이터 삭제 */ + @DeleteMapping("/contract/export/all") + public String deleteAllContractExportData() { + Set keys = redisTemplate.keys("contract:export:*"); + if (keys != null && !keys.isEmpty()) { + Long deleted = redisTemplate.delete(keys); + log.info("Deleted {} export keys", deleted); + return "Deleted " + deleted + " keys"; + } + return "No keys found"; + } + + /** Redis의 모든 데이터 삭제 (위험!) 개발 환경에서만 사용할 것 */ + @DeleteMapping("/flush-all") + public String flushAll() { + log.warn("FLUSHING ALL REDIS DATA!"); + redisTemplate.getConnectionFactory().getConnection().flushAll(); + return "All Redis data has been deleted!"; + } + + /** 현재 데이터베이스의 모든 키 삭제 */ + @DeleteMapping("/flush-db") + public String flushDb() { + log.warn("FLUSHING CURRENT REDIS DATABASE!"); + redisTemplate.getConnectionFactory().getConnection().flushDb(); + return "Current Redis database has been cleared!"; + } +} diff --git a/src/main/java/org/scoula/domain/contract/dto/ContractExportStatusDTO.java b/src/main/java/org/scoula/domain/contract/dto/ContractExportStatusDTO.java new file mode 100644 index 00000000..8a9955bc --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/ContractExportStatusDTO.java @@ -0,0 +1,65 @@ +package org.scoula.domain.contract.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 계약서 내보내기 진행 상태를 추적하는 DTO */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ContractExportStatusDTO { + + // 계약 정보 + private Long contractChatId; + private Long ownerId; + private Long buyerId; + + // 진행 상태 + private String currentStep; // preview, signature, password, complete + + // 서명 상태 + private boolean ownerSignatureCompleted; + private boolean buyerSignatureCompleted; + private List ownerSignatures; // base64 encoded signatures + private List buyerSignatures; + + // 체크박스 상태 + private boolean ownerHasTaxArrears; + private boolean ownerHasPriorFixedDate; + private boolean ownerMediationAgree; + private boolean buyerMediationAgree; + + // 암호 설정 상태 + private boolean ownerPasswordSet; + private boolean buyerPasswordSet; + + // 최종 상태 + private boolean isCompleted; + private String finalPdfUrl; + + // 타임스탬프 + private Long lastUpdated; + + /** 양측이 모두 서명을 완료했는지 확인 */ + public boolean isBothSignaturesCompleted() { + return ownerSignatureCompleted && buyerSignatureCompleted; + } + + /** 양측이 모두 암호를 설정했는지 확인 */ + public boolean isBothPasswordsSet() { + return ownerPasswordSet && buyerPasswordSet; + } + + /** 계약서 내보내기가 완료 가능한지 확인 */ + public boolean isReadyForCompletion() { + return true; + } +} diff --git a/src/main/java/org/scoula/domain/contract/dto/ContractPasswordDTO.java b/src/main/java/org/scoula/domain/contract/dto/ContractPasswordDTO.java new file mode 100644 index 00000000..c8cb4ab7 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/ContractPasswordDTO.java @@ -0,0 +1,22 @@ +package org.scoula.domain.contract.dto; + +import javax.validation.constraints.NotNull; + +import io.swagger.annotations.ApiModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "최종 계약서 비밀번호 받아오기") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContractPasswordDTO { + + @NotNull private String contractPassword; + // private String contractBuyerPassword; + + @NotNull private Boolean mediationAgree; // 동의 여부 +} diff --git a/src/main/java/org/scoula/domain/contract/dto/DBFinalContractDTO.java b/src/main/java/org/scoula/domain/contract/dto/DBFinalContractDTO.java new file mode 100644 index 00000000..0f413875 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/DBFinalContractDTO.java @@ -0,0 +1,50 @@ +package org.scoula.domain.contract.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.scoula.domain.precontract.enums.ContractDuration; + +import io.swagger.annotations.ApiModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "DB에 있는 최종 계약서에 들어가는 내용들") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DBFinalContractDTO { + + private String leaseType; + private String landCategory; // home detail _ 토지 지목 + private BigDecimal area; // home detail _ 토지 면적 + + private String buildingStructure; // 건물 구조 _ '철근콘크리트 구조'로 고정하기 + // private String purpose; // 성엽님 _ 건물 용도 + // private float totalFloorArea; // 성엽님 _ 건물 면적 + + private boolean hasTaxArrears; // owner pre contract check _ 미납 국세, 지방세 여부 + private boolean hasPriorFixedDate; // owner pre contract check _ 선순위 확정일자 현황 + + private int paymentDueDay; // owner_wolse_info _ 매월 지불 일자 + private String bankAccount; // owner pre contract check _ 입금 계좌 & 은행 : owner_bank_name & + // owner_account_number + + private LocalDate expectedMoveInDate; // tenant pre contract check 입주 날짜 -> === 특약사항에도 들어감 === + + private ContractDuration + contractDuration; // Tenant pre contract check에서 contract_duration으로 퇴거 날짜 계산해서 넣기 + private LocalDate contractDate; // 계약하는 날짜 now()써서 하기 + + private String ownerSsnFront; // identity verification ssnFront + ssnBack 합쳐서 넣기 + private String ownerSsnBack; + private String buyerSsnFront; + private String buyerSsnBack; + + // 주소 정보 + private String homeAddr1; + private String homeAddr2; +} diff --git a/src/main/java/org/scoula/domain/contract/dto/FinalContractDTO.java b/src/main/java/org/scoula/domain/contract/dto/FinalContractDTO.java index 05838e4a..8b108508 100644 --- a/src/main/java/org/scoula/domain/contract/dto/FinalContractDTO.java +++ b/src/main/java/org/scoula/domain/contract/dto/FinalContractDTO.java @@ -1,6 +1,7 @@ package org.scoula.domain.contract.dto; -import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.math.BigDecimal; import io.swagger.annotations.ApiModel; import lombok.AllArgsConstructor; @@ -10,17 +11,158 @@ @ApiModel(description = "최종 계약서") @Data -@Builder +@Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FinalContractDTO { - private MultipartFile ownerTaxSignature; - private MultipartFile ownerPrioritySignature; - private MultipartFile ownerContractSignature; - private MultipartFile buyerContractSignature; + private Boolean leaseType; // home _ 전세 : True / 월세 : False + private String ownerNickname; // user _ 임대인 이름 + private String buyerNickname; // user_임차인 이름 - private Boolean mediationAgree; // 조정 동의 여부 + // 임차주택의 표시 + private String addr1; // home _ addr1 + private String landCategory; // home detail _ 토지 지목 + private BigDecimal area; // home detail _ 토지 면적 - private String contractKey; // 계약서 비밀번호 + private String buildingStructure; // 건물 구조 _ '철근콘크리트 구조'로 고정하기 + private String purpose; // 성엽님 _ 건물 용도 + private float totalFloorArea; // 성엽님 _ 건물 면적 + + private String addr2; // home_ addr2 _ 임차할 부분 주소 + private float supplyArea; // home _ 임차할 부분 면적 + + private boolean hasTaxArrears; // owner pre contract check _ 미납 국세, 지방세 여부 + private boolean hasPriorFixedDate; // owner pre contract check _ 선순위 확정일자 현황 + + // 계약 내용 + private String textDepositPrice; // 보증금 금액 : 한글 + private int depositPrice; // home_보증금 금액 : 숫자만 (,도 없음) + private int monthlyRent; // home _ 차임(월세)원정 + private int paymentDueDay; // owner_wolse_info _ 매월 지불 일자 + private String + bankAccount; // owner wolse info _ 입금 계좌 & 은행 : owner_bank_name & owner_account_number + private String textMaintenanceFee; // home _ 관리비 : 한글 + private int maintenanceFee; // home : 숫자만 (,도 없음) + + // 2조 임대차기간 + private int expectedMoveInYear; // tenant pre contract check 입주 날짜 -> === 특약사항에도 들어감 === + private int expectedMoveInMonth; + private int expectedMoveInDay; // -> 이거 그냥 하나로 넘겨서 나누면 될듯! + + private int + expectedMoveOutYear; // Tenant pre contract check에서 contract_duration으로 퇴거 날짜 계산해서 넣기 + private int expectedMoveOutMonth; + private int expectedMoveOutDay; + + private int contractDateYear; // 계약하는 날짜 now()써서 하기 + private int contractDateMonth; + private int contractDateDay; + + // 마지막 사인 + private String ownerAddr; // identity_verifiacation에서 addr1 + addr2 합쳐서 넣기 + private String ownerSsn; // identity verification ssnFront + ssnBack 합쳐서 넣기 + private String ownerPhoneNumber; // identity verification + // 임대인 이름은 위쪽에 있음 + + private String buyerAddr; + private String buyerSsn; + private String buyerPhoneNumber; + + // ---------------------------- + + private File ownerTaxSignature; // 미납 국세 지방세 Nullable + private File ownerPrioritySignature; // 선순위 확정일자 현황 nullable + + private File ownerContractSignature; + private File buyerContractSignature; + + private Boolean ownerMediationAgree; // 조정 동의 여부 + private Boolean buyerMediationAgree; // 조정 동의 여부 + + // private String contractKey; // 계약서 비밀번호 + + // public static FinalContractDTO toDTO( + // DBFinalContractDTO dto, + // boolean leaseType, + // String buildingStructure, + // String textDepositPrice, + // String textMaintenanceFee, + // LocalDate expectedMoveOut, + // String ownerSsn, + // String buyerSsn, + // ContractMongoDocument document, + // IdentityVerificationInfoVO ownerVO, + // IdentityVerificationInfoVO buyerVO, + // Boolean mediationAgree, + // Boolean mediationAgreed) { + // return FinalContractDTO.builder() + // .leaseType(leaseType) + // .ownerNickname(document.getOwnerName()) + // .buyerNickname(document.getBuyerName()) + // .addr1(document.getHomeAddr1()) + // .landCategory(dto.getLandCategory()) + // .area(dto.getArea()) + // .buildingStructure(buildingStructure) + // // .purpose(dto.getPurpose()) + // // .totalFloorArea(dto.getTotalFloorArea()) + // .addr2(document.getHomeAddr2()) + // .supplyArea(document.getExclusiveArea()) + // .hasTaxArrears(dto.isHasTaxArrears()) + // .hasPriorFixedDate(dto.isHasPriorFixedDate()) + // .textDepositPrice(textDepositPrice) + // .depositPrice(document.getDepositPrice()) + // .monthlyRent(document.getMonthlyRent()) + // .paymentDueDay(dto.getPaymentDueDay()) + // .bankAccount(dto.getBankAccount()) + // .textMaintenanceFee(textMaintenanceFee) + // .maintenanceFee(document.getMaintenanceFee()) + // .expectedMoveInYear(dto.getExpectedMoveInDate().getYear()) + // .expectedMoveInMonth(dto.getExpectedMoveInDate().getMonthValue()) + // .expectedMoveInDay(dto.getExpectedMoveInDate().getDayOfMonth()) + // .expectedMoveOutYear(expectedMoveOut.getYear()) + // .expectedMoveOutMonth(expectedMoveOut.getMonthValue()) + // .expectedMoveOutDay(expectedMoveOut.getDayOfMonth()) + // .contractDateYear(dto.getContractDate().getYear()) + // .contractDateMonth(dto.getContractDate().getMonthValue()) + // .contractDateDay(dto.getContractDate().getDayOfMonth()) + // .ownerAddr(ownerVO.getAddr1() + " " + ownerVO.getAddr2()) + // .ownerSsn(ownerSsn) + // .ownerPhoneNumber(ownerVO.getPhoneNumber()) + // .buyerAddr(buyerVO.getAddr1() + " " + buyerVO.getAddr2()) + // .buyerSsn(buyerSsn) + // .buyerPhoneNumber(buyerVO.getPhoneNumber()) + // // .mediationAgree(mediationAgree) + // .build(); + // } + // + // public static FinalContractDTO toDTOs(File file, SignedType signedType) { + // FinalContractDTO.FinalContractDTOBuilder builder = FinalContractDTO.builder(); + // + // switch (signedType) { + // case TAX: + // builder.ownerTaxSignature(file); + // break; + // case PRIORITY: + // builder.ownerPrioritySignature(file); + // break; + // case OWNER_CONTRACT: + // builder.ownerContractSignature(file); + // break; + // case BUYER_CONTRACT: + // builder.buyerContractSignature(file); + // break; + // default: + // throw new IllegalArgumentException("지원하지 않는 서명 타입입니다: " + signedType); + // } + // + // return builder.build(); + // } + // + // public static FinalContractDTO toAgreeDTO(Boolean mediationAgree, File contractPDF) { + // return FinalContractDTO.builder() + // // .mediationAgree(mediationAgree) + // // .contractPDF(contractPDF) + // .build(); + // } } diff --git a/src/main/java/org/scoula/domain/contract/dto/FindContractDTO.java b/src/main/java/org/scoula/domain/contract/dto/FindContractDTO.java new file mode 100644 index 00000000..d9e761e2 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/FindContractDTO.java @@ -0,0 +1,19 @@ +package org.scoula.domain.contract.dto; + +import io.swagger.annotations.ApiModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "최종 계약서 조회/전송") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FindContractDTO { + + private String email; // 계약서를 받을 이메일 주소 (이메일 전송 시 사용) + + @Deprecated private String contractPassword; // 기존 호환성을 위해 유지 (사용하지 않음) +} diff --git a/src/main/java/org/scoula/domain/contract/dto/LegalityRequestDTO.java b/src/main/java/org/scoula/domain/contract/dto/LegalityRequestDTO.java index ec2ed78d..17de72b7 100644 --- a/src/main/java/org/scoula/domain/contract/dto/LegalityRequestDTO.java +++ b/src/main/java/org/scoula/domain/contract/dto/LegalityRequestDTO.java @@ -2,11 +2,10 @@ import lombok.*; -@Getter -@Setter +@Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder public class LegalityRequestDTO { private String legalBasis; private Long requestId; diff --git a/src/main/java/org/scoula/domain/contract/dto/PasswordSubmitDTO.java b/src/main/java/org/scoula/domain/contract/dto/PasswordSubmitDTO.java new file mode 100644 index 00000000..89dcf162 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/PasswordSubmitDTO.java @@ -0,0 +1,17 @@ +package org.scoula.domain.contract.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 암호 제출 DTO */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PasswordSubmitDTO { + private String userRole; // "owner" or "buyer" + private String password; + private Long submittedAt; +} diff --git a/src/main/java/org/scoula/domain/contract/dto/PrioritySignatureDTO.java b/src/main/java/org/scoula/domain/contract/dto/PrioritySignatureDTO.java new file mode 100644 index 00000000..630ac87c --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/PrioritySignatureDTO.java @@ -0,0 +1,24 @@ +package org.scoula.domain.contract.dto; + +import java.time.LocalDateTime; + +import org.scoula.global.common.constant.Constants; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import io.swagger.annotations.ApiModelProperty; + +public class PrioritySignatureDTO { + + @ApiModelProperty(value = "owner_Priority 서명 이미지 S3 URL", example = "url") + private String ownerPrioritySignatureFileKey; + + @ApiModelProperty(value = "owner_Priority 서명 Hash key", example = "Hash key") + private String ownerPriorityFileHash; + + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = Constants.DateTime.DEFAULT_DATETIME_FORMAT) + @ApiModelProperty(value = "owner_Priority 서명 날짜/시간", example = "2024-08-08 14:23:00") + private LocalDateTime ownerPrioritySignedAt; +} diff --git a/src/main/java/org/scoula/domain/contract/dto/SaveFinalContractDTO.java b/src/main/java/org/scoula/domain/contract/dto/SaveFinalContractDTO.java new file mode 100644 index 00000000..d256574d --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/SaveFinalContractDTO.java @@ -0,0 +1,138 @@ +package org.scoula.domain.contract.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +import org.scoula.domain.contract.document.ContractMongoDocument; +import org.scoula.domain.precontract.vo.IdentityVerificationInfoVO; + +import io.swagger.annotations.ApiModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "AI에 보낼 최종 계약서에 들어가는 내용들") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SaveFinalContractDTO { + + private Boolean leaseType; // 전세: true, 월세: false + private String ownerNickname; // user _ 임대인 이름 + private String buyerNickname; // user_임차인 이름 + + // 임차주택의 표시 + private String addr1; // home _ addr1 + private String landCategory; // home detail _ 토지 지목 + private String area; // home detail _ 토지 면적 (String으로 변경) + + private String buildingStructure; // 건물 구조 _ '철근콘크리트 구조'로 고정하기 + private String purpose; // 성엽님 _ 건물 용도 + private String totalFloorArea; // 성엽님 _ 건물 면적 (String으로 변경) + + private String addr2; // home_ addr2 _ 임차할 부분 주소 + private String supplyArea; // home _ 임차할 부분 면적 (String으로 변경) + + private Boolean hasTaxArrears; // owner pre contract check _ 미납 국세, 지방세 여부 + private Boolean hasPriorFixedDate; // owner pre contract check _ 선순위 확정일자 현황 + + // 계약 내용 + private String textDepositPrice; // 보증금 금액 : 한글 + private String depositPrice; // home_보증금 금액 : 숫자만 (String으로 변경) + private String monthlyRent; // home _ 차임(월세)원정 (String으로 변경) + private String paymentDueDay; // owner_wolse_info _ 매월 지불 일자 (String으로 변경) + private String bankAccount; // owner wolse info _ 입금 계좌 & 은행 + private String textMaintenanceFee; // home _ 관리비 : 한글 + private String maintenanceFee; // home : 숫자만 (String으로 변경) + + // 2조 임대차기간 + private String expectedMoveInYear; // tenant pre contract check 입주 날짜 + private String expectedMoveInMonth; + private String expectedMoveInDay; + + private String expectedMoveOutYear; // 퇴거 날짜 + private String expectedMoveOutMonth; + private String expectedMoveOutDay; + + private String contractDateYear; // 계약하는 날짜 + private String contractDateMonth; + private String contractDateDay; + + // 마지막 사인 + private String ownerAddr; // identity_verifiacation에서 addr1 + addr2 합쳐서 넣기 + private String ownerSsn; // identity verification ssnFront + ssnBack 합쳐서 넣기 + private String ownerPhoneNumber; // identity verification + + private String buyerAddr; + private String buyerSsn; + private String buyerPhoneNumber; + + // 특약사항 + private List special; // 특약사항 리스트 + + // 서명 이미지 (base64 인코딩) + private String ownerSign1Base64; + private String ownerSign2Base64; + private String ownerSign3Base64; + private String buyerSignBase64; + + public static SaveFinalContractDTO toDTO( + DBFinalContractDTO dto, + boolean leaseType, + String buildingStructure, + String textDepositPrice, + String textMaintenanceFee, + LocalDate expectedMoveOut, + String ownerSsn, + String buyerSsn, + ContractMongoDocument document, + IdentityVerificationInfoVO ownerVO, + IdentityVerificationInfoVO buyerVO) { + return SaveFinalContractDTO.builder() + .leaseType(leaseType) // boolean 값 그대로 전달 + .ownerNickname(document.getOwnerName()) + .buyerNickname(document.getBuyerName()) + .addr1(document.getHomeAddr1()) + .landCategory(dto.getLandCategory()) + .area(String.valueOf(dto.getArea())) + .buildingStructure(buildingStructure) + .purpose("주택") // 기본값 설정 + .totalFloorArea("100") // 기본값 설정 + .addr2(document.getHomeAddr2()) + .supplyArea(String.valueOf(document.getExclusiveArea())) + .hasTaxArrears(dto.isHasTaxArrears()) + .hasPriorFixedDate(dto.isHasPriorFixedDate()) + .textDepositPrice(textDepositPrice) + .depositPrice(String.valueOf(document.getDepositPrice())) + .monthlyRent(String.valueOf(document.getMonthlyRent())) + .paymentDueDay(String.valueOf(dto.getPaymentDueDay())) + .bankAccount(dto.getBankAccount()) + .textMaintenanceFee(textMaintenanceFee) + .maintenanceFee(String.valueOf(document.getMaintenanceFee())) + .expectedMoveInYear(String.valueOf(dto.getExpectedMoveInDate().getYear())) + .expectedMoveInMonth(String.valueOf(dto.getExpectedMoveInDate().getMonthValue())) + .expectedMoveInDay(String.valueOf(dto.getExpectedMoveInDate().getDayOfMonth())) + .expectedMoveOutYear(String.valueOf(expectedMoveOut.getYear())) + .expectedMoveOutMonth(String.valueOf(expectedMoveOut.getMonthValue())) + .expectedMoveOutDay(String.valueOf(expectedMoveOut.getDayOfMonth())) + .contractDateYear(String.valueOf(dto.getContractDate().getYear())) + .contractDateMonth(String.valueOf(dto.getContractDate().getMonthValue())) + .contractDateDay(String.valueOf(dto.getContractDate().getDayOfMonth())) + .ownerAddr(ownerVO.getAddr1() + " " + ownerVO.getAddr2()) + .ownerSsn(ownerSsn) + .ownerPhoneNumber(ownerVO.getPhoneNumber()) + .buyerAddr(buyerVO.getAddr1() + " " + buyerVO.getAddr2()) + .buyerSsn(buyerSsn) + .buyerPhoneNumber(buyerVO.getPhoneNumber()) + .special( + document.getSpecialContracts() != null + ? document.getSpecialContracts().stream() + .map(sc -> sc.getContent()) + .collect(Collectors.toList()) + : null) + .build(); + } +} diff --git a/src/main/java/org/scoula/domain/contract/dto/SaveSignatureDTO.java b/src/main/java/org/scoula/domain/contract/dto/SaveSignatureDTO.java new file mode 100644 index 00000000..0ba28c9b --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/SaveSignatureDTO.java @@ -0,0 +1,38 @@ +package org.scoula.domain.contract.dto; + +import java.util.List; + +import org.scoula.domain.contract.enums.SignedType; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@ApiModel(description = "사인 저장하기") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SaveSignatureDTO { + + @ApiModelProperty( + value = "어떤 서명인지 ENUM으로 ", + example = "OWNER_CONTRACT", + allowableValues = "TAX, PRIORITY, OWNER_CONTRACT, BUYER_CONTRACT") + private SignedType signedType; + + @ApiModelProperty(value = "세금 체납 없음 확인", example = "false") + private Boolean hasTaxArrears; + + @ApiModelProperty(value = "선순위 확정일자 없음 확인", example = "false") + private Boolean hasPriorFixedDate; + + @ApiModelProperty(value = "조정 동의", example = "true") + private Boolean mediationAgree; + + @ApiModelProperty(value = "서명 이미지 데이터 (base64)", example = "[\"data:image/png;base64,...\"]") + private List signatureImages; +} diff --git a/src/main/java/org/scoula/domain/contract/dto/SignatureSubmitDTO.java b/src/main/java/org/scoula/domain/contract/dto/SignatureSubmitDTO.java new file mode 100644 index 00000000..cc8403bc --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/SignatureSubmitDTO.java @@ -0,0 +1,30 @@ +package org.scoula.domain.contract.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 서명 제출 DTO - 임대인/임차인 각각의 서명 데이터 */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SignatureSubmitDTO { + + // 사용자 역할 + private String userRole; // "owner" or "buyer" + + // 서명 데이터 (base64 encoded) + private String signature1; // 계약서 서명 (필수) + private String signature2; // 세금 체납 없음 서명 (조건부) + private String signature3; // 선순위 확정일자 없음 서명 (조건부) + + // 체크박스 상태 + private boolean hasTaxArrears; + private boolean hasPriorFixedDate; + private boolean mediationAgree; + + // 타임스탬프 + private Long submittedAt; +} diff --git a/src/main/java/org/scoula/domain/contract/enums/SignedType.java b/src/main/java/org/scoula/domain/contract/enums/SignedType.java new file mode 100644 index 00000000..43b18c0b --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/enums/SignedType.java @@ -0,0 +1,15 @@ +package org.scoula.domain.contract.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SignedType { + TAX("미납 국세, 지방세 사인"), + PRIORITY("선순위 확정일자 현황"), + OWNER_CONTRACT("암대인 최종 사인"), + BUYER_CONTRACT("임차인 최종 사"); + + private final String displayName; +} diff --git a/src/main/java/org/scoula/domain/contract/exception/ContractException.java b/src/main/java/org/scoula/domain/contract/exception/ContractException.java index e2ad06a3..62dfc1f3 100644 --- a/src/main/java/org/scoula/domain/contract/exception/ContractException.java +++ b/src/main/java/org/scoula/domain/contract/exception/ContractException.java @@ -14,7 +14,14 @@ public enum ContractException implements IErrorCode { CONTRACT_AI_SERVER_ERROR( "CONTRACT_4003", HttpStatus.SERVICE_UNAVAILABLE, "AI 서버 통신 중 오류가 발생했습니다."), CONTRACT_UPDATE("CONTRACT_4004", HttpStatus.BAD_REQUEST, "MongoDB에 수정이 되지 않았습니다"), - CONTRACT_REDIS("CONTRACT_4005", HttpStatus.BAD_REQUEST, "REDIS에 해당 정보가 없습니다."); + CONTRACT_REDIS("CONTRACT_4005", HttpStatus.BAD_REQUEST, "REDIS에 해당 정보가 없습니다."), + CONTRACT_DB_INSERT("CONTRACT_4006", HttpStatus.BAD_REQUEST, "DB에 저장되지 않았습니다."), + CONTRACT_DB_UPDATE("CONTRACT_4007", HttpStatus.BAD_REQUEST, "DB에 수정되지 않았습니다."), + CONTRACT_AGREEMENT( + "CONTRACT_4008", HttpStatus.BAD_REQUEST, "최종 계약서에 동의가 되지 않아 계약서를 완료할 수 없습니다."), + CONTRACT_NOT_FOUND("CONTRACT_4009", HttpStatus.NOT_FOUND, "계약서를 찾을 수 없습니다."), + PDF_GENERATION_FAILED("CONTRACT_4010", HttpStatus.INTERNAL_SERVER_ERROR, "PDF 생성에 실패했습니다."); + private final String code; private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/org/scoula/domain/contract/mapper/ContractMapper.java b/src/main/java/org/scoula/domain/contract/mapper/ContractMapper.java index 86ba0dc1..711b97a7 100644 --- a/src/main/java/org/scoula/domain/contract/mapper/ContractMapper.java +++ b/src/main/java/org/scoula/domain/contract/mapper/ContractMapper.java @@ -1,8 +1,14 @@ package org.scoula.domain.contract.mapper; +import java.util.List; + import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.scoula.domain.contract.dto.ContractDTO; +import org.scoula.domain.contract.dto.DBFinalContractDTO; +import org.scoula.domain.contract.enums.SignedType; +import org.scoula.domain.contract.vo.ElectronicSignature; +import org.scoula.domain.contract.vo.FinalContract; @Mapper public interface ContractMapper { @@ -19,14 +25,48 @@ public interface ContractMapper { Long selectFinalContractId(@Param("contractChatId") Long contractChatId); - int insertSignatureInit(@Param("contractChatId") Long contractChatId); + // ============== + + int insertFinalContractInit( + @Param("contractChatId") Long contractChatId, + @Param("depositPrice") int depositPrice, + @Param("monthlyRent") int monthlyRent, + @Param("maintenanceFee") int maintenanceFee); + + int insertContract(@Param("contractChatId") Long contractChatId, @Param("s3Key") String s3Key); + + DBFinalContractDTO selectFinalContractPDF(@Param("contractChatId") Long contractChatId); + + FinalContract selectFinalContract(@Param("contractChatId") Long contractChatId); + + int insertSignature( + @Param("contractChatId") Long contractChatId, + @Param("s3Key") String s3Key, + @Param("hashKey") String hashKey, + @Param("signedType") SignedType signedType, + @Param("userId") Long userId); + + List selectSignature( + @Param("contractChatId") Long contractChatId, @Param("userId") Long userId); + + int updateFinalContract( + @Param("contractChatId") Long contractChatId, + @Param("contractPdfKey") String contractPdfKey, + @Param("contractPdfHash") String contractPdfHash); + + int insertOrUpdateFinalContract( + @Param("contractChatId") Long contractChatId, + @Param("contractPdfKey") String contractPdfKey, + @Param("contractPdfHash") String contractPdfHash); + + String selectBirth(@Param("userId") Long userId); + + String selectSsnFront( + @Param("userId") Long userId, @Param("contractChatId") Long contractChatId); - int updateTaxSignature( - @Param("finalContractId") Long finalContractId, - @Param("url") String url, - @Param("hashKey") String hashKey); + String selectMail(@Param("userId") Long userId); - int insertFinalContract(@Param("contractChatId") Long contractChatId); + // ======== String selectOwnerTaxSignatureUrl(@Param("finalContractId") Long finalContractId); 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 8b8b3681..674c5624 100644 --- a/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java +++ b/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java @@ -29,6 +29,7 @@ public ContractMongoDocument saveContractMongo(ContractDTO dto, LocalDate contra return mongoTemplate.insert(document); } + // 해당하는 contractChatId를 다 가져오기 public ContractMongoDocument getContract(Long contractChatId) { Query contractQuery = new Query(Criteria.where("contractChatId").is(contractChatId)); ContractMongoDocument document = diff --git a/src/main/java/org/scoula/domain/contract/service/ContractExportSyncService.java b/src/main/java/org/scoula/domain/contract/service/ContractExportSyncService.java new file mode 100644 index 00000000..0804190f --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/service/ContractExportSyncService.java @@ -0,0 +1,1025 @@ +package org.scoula.domain.contract.service; + +import java.io.*; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.scoula.domain.chat.mapper.ContractChatMapper; +import org.scoula.domain.chat.vo.ContractChat; +import org.scoula.domain.contract.dto.*; +import org.scoula.global.file.service.S3ServiceInterface; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +/** 계약서 내보내기 동기화 서비스 Redis를 사용하여 양측 상태를 관리하고 동기화 */ +@Service +@RequiredArgsConstructor +@Log4j2 +public class ContractExportSyncService { + + private final RedisTemplate redisTemplate; + private final ContractService contractService; + private final ContractChatMapper contractChatMapper; + private final S3ServiceInterface s3Service; + private final SimpMessagingTemplate messagingTemplate; + private static final String EXPORT_STATUS_KEY = "contract:export:status:"; + private static final String EXPORT_PASSWORD_KEY = "contract:export:password:"; + private static final long EXPIRE_TIME = 2; // 2시간 + + /** 계약서 내보내기 상태 조회 */ + public ContractExportStatusDTO getExportStatus(Long contractChatId) { + try { + String key = EXPORT_STATUS_KEY + contractChatId; + ContractExportStatusDTO status = + (ContractExportStatusDTO) redisTemplate.opsForValue().get(key); + + if (status == null) { + log.info("Creating initial export status for contractChatId: {}", contractChatId); + // 초기 상태 생성 + status = createInitialStatus(contractChatId); + saveExportStatus(contractChatId, status); + } + + return status; + } catch (Exception e) { + log.error("Redis error, returning default status: ", e); + // Redis 에러 시 기본 상태 반환 + return createInitialStatus(contractChatId); + } + } + + /** 계약서 내보내기 상태 저장 */ + public void saveExportStatus(Long contractChatId, ContractExportStatusDTO status) { + String key = EXPORT_STATUS_KEY + contractChatId; + status.setLastUpdated(System.currentTimeMillis()); + redisTemplate.opsForValue().set(key, status, EXPIRE_TIME, TimeUnit.HOURS); + } + + /** 서명 업데이트 */ + public ContractExportStatusDTO updateSignature( + Long contractChatId, SignatureSubmitDTO signatureData) { + log.info( + "updateSignature called for contract {} with role {}", + contractChatId, + signatureData.getUserRole()); + ContractExportStatusDTO status = getExportStatus(contractChatId); + log.info( + "Current status before update: owner={}, buyer={}", + status.isOwnerSignatureCompleted(), + status.isBuyerSignatureCompleted()); + + if ("owner".equals(signatureData.getUserRole())) { + // 임대인 서명 업데이트 - data URL 접두사 제거 + String sig1 = signatureData.getSignature1(); + String sig2 = signatureData.getSignature2(); + String sig3 = signatureData.getSignature3(); + + // data:image/png;base64, 접두사 제거 + if (sig1 != null && sig1.startsWith("data:")) { + sig1 = sig1.substring(sig1.indexOf(",") + 1); + } + if (sig2 != null && sig2.startsWith("data:")) { + sig2 = sig2.substring(sig2.indexOf(",") + 1); + } + if (sig3 != null && sig3.startsWith("data:")) { + sig3 = sig3.substring(sig3.indexOf(",") + 1); + } + + status.setOwnerSignatures(List.of(sig1, sig2, sig3)); + status.setOwnerSignatureCompleted(true); + status.setOwnerHasTaxArrears(signatureData.isHasTaxArrears()); + status.setOwnerHasPriorFixedDate(signatureData.isHasPriorFixedDate()); + // 중재 동의는 무조건 true로 설정 + status.setOwnerMediationAgree(true); + + } else if ("buyer".equals(signatureData.getUserRole())) { + // 임차인 서명 업데이트 - data URL 접두사 제거 + log.info("=== Buyer Signature Update ==="); + String buyerSig1 = signatureData.getSignature1(); + + log.info( + "Buyer signature1 received: {}", + buyerSig1 != null ? "present (length: " + buyerSig1.length() + ")" : "null"); + + // data:image/png;base64, 접두사 제거 + if (buyerSig1 != null && buyerSig1.startsWith("data:")) { + log.info("Removing data URL prefix from buyer signature"); + buyerSig1 = buyerSig1.substring(buyerSig1.indexOf(",") + 1); + log.info("After removing prefix, length: {}", buyerSig1.length()); + } + + if (buyerSig1 != null && buyerSig1.length() > 0) { + log.info( + "Buyer signature1 preview after processing: {}", + buyerSig1.substring(0, Math.min(50, buyerSig1.length()))); + } + + status.setBuyerSignatures(List.of(buyerSig1)); + status.setBuyerSignatureCompleted(true); + // 중재 동의는 무조건 true로 설정 + status.setBuyerMediationAgree(true); + + log.info( + "Buyer signatures after update: {}", + status.getBuyerSignatures() != null ? status.getBuyerSignatures().size() : 0); + log.info("================================"); + } + + // 양측 서명 완료 확인 + log.info( + "Signature status after update - Owner: {}, Buyer: {}, Both: {}", + status.isOwnerSignatureCompleted(), + status.isBuyerSignatureCompleted(), + status.isBothSignaturesCompleted()); + + // 양측 서명 완료 시 자동으로 최종 계약서 생성 + if (status.isBothSignaturesCompleted()) { + log.info( + "Both signatures completed for contract {}. Auto-generating final contract with" + + " both signatures.", + contractChatId); + status.setCurrentStep("generating"); + + // 자동으로 최종 PDF 생성 시도 + try { + // 임대인과 임차인의 생년월일을 암호로 사용 + String ownerPassword = + contractService.getUserBirthDate( + contractChatId, status.getOwnerId(), "owner"); + String buyerPassword = + contractService.getUserBirthDate( + contractChatId, status.getBuyerId(), "buyer"); + + // Redis에 암호 저장 + String ownerPasswordKey = EXPORT_PASSWORD_KEY + contractChatId + ":owner"; + String buyerPasswordKey = EXPORT_PASSWORD_KEY + contractChatId + ":buyer"; + redisTemplate + .opsForValue() + .set(ownerPasswordKey, ownerPassword, EXPIRE_TIME, TimeUnit.HOURS); + redisTemplate + .opsForValue() + .set(buyerPasswordKey, buyerPassword, EXPIRE_TIME, TimeUnit.HOURS); + + status.setOwnerPasswordSet(true); + status.setBuyerPasswordSet(true); + + // 실제 서명이 포함된 최종 PDF 생성 + try { + log.info("Starting PDF generation for contract {}", contractChatId); + String finalPdfUrl = generateSignedPdf(contractChatId, status); + + if (finalPdfUrl != null && !finalPdfUrl.isEmpty()) { + status.setCurrentStep("complete"); + status.setCompleted(true); + status.setFinalPdfUrl(finalPdfUrl); + + log.info( + "Final signed PDF generated for contract {}: {}", + contractChatId, + finalPdfUrl); + } else { + log.error("PDF URL is null or empty for contract {}", contractChatId); + status.setCurrentStep("error"); + status.setFinalPdfUrl(null); + } + } catch (Exception pdfError) { + log.error( + "Failed to generate signed PDF for contract {}: {}", + contractChatId, + pdfError.getMessage(), + pdfError); + status.setCurrentStep("error"); + status.setFinalPdfUrl(null); + } + } catch (Exception e) { + log.error("Failed to auto-generate final PDF for contract {}", contractChatId, e); + status.setCurrentStep("password"); // 실패 시 암호 단계로 + } + } else { + // 한쪽만 서명한 경우 - 대기 상태 설정 + log.info( + "Waiting for other party's signature for contract {}. Owner signed: {}, Buyer" + + " signed: {}", + contractChatId, + status.isOwnerSignatureCompleted(), + status.isBuyerSignatureCompleted()); + status.setCurrentStep("waiting"); + } + + saveExportStatus(contractChatId, status); + + // WebSocket으로 상태 브로드캐스트 (상대방에게 알림) + broadcastStatusUpdate(contractChatId, status); + + return status; + } + + /** 암호 업데이트 */ + public ContractExportStatusDTO updatePassword( + Long contractChatId, PasswordSubmitDTO passwordData) { + ContractExportStatusDTO status = getExportStatus(contractChatId); + + // Redis에 암호 저장 (보안을 위해 별도 키로 저장) + String passwordKey = + EXPORT_PASSWORD_KEY + contractChatId + ":" + passwordData.getUserRole(); + redisTemplate + .opsForValue() + .set(passwordKey, passwordData.getPassword(), EXPIRE_TIME, TimeUnit.HOURS); + + if ("owner".equals(passwordData.getUserRole())) { + status.setOwnerPasswordSet(true); + } else if ("buyer".equals(passwordData.getUserRole())) { + status.setBuyerPasswordSet(true); + } + + // 양측 암호 설정 완료 시 최종 PDF 생성 준비 + if (status.isBothPasswordsSet()) { + status.setCurrentStep("generating"); + log.info( + "Both passwords set for contract {}. Ready to generate final PDF.", + contractChatId); + } + + saveExportStatus(contractChatId, status); + return status; + } + + /** 최종 PDF 생성 */ + public String generateFinalPdf(Long contractChatId) throws Exception { + ContractExportStatusDTO status = getExportStatus(contractChatId); + + if (!status.isReadyForCompletion()) { + throw new IllegalStateException( + "Not ready for PDF generation. Missing signatures or passwords."); + } + + // 양측 암호 가져오기 + String ownerPassword = + (String) + redisTemplate + .opsForValue() + .get(EXPORT_PASSWORD_KEY + contractChatId + ":owner"); + String buyerPassword = + (String) + redisTemplate + .opsForValue() + .get(EXPORT_PASSWORD_KEY + contractChatId + ":buyer"); + + // 암호 결합 (예: 두 암호를 연결하거나 XOR 등의 방식 사용) + String combinedPassword = combinePasswords(ownerPassword, buyerPassword); + + // 최종 PDF 생성 (기존 서비스 활용) + ContractPasswordDTO passwordDTO = new ContractPasswordDTO(); + passwordDTO.setContractPassword(combinedPassword); + passwordDTO.setMediationAgree( + status.isOwnerMediationAgree() && status.isBuyerMediationAgree()); + + // PDF 생성 및 S3 업로드 + byte[] finalPdf = + contractService.saveFinalContract(contractChatId, status.getOwnerId(), passwordDTO); + + // S3 URL 반환 (실제 구현 필요) + String pdfUrl = uploadToS3(contractChatId, finalPdf); + + // 상태 업데이트 + status.setCompleted(true); + status.setFinalPdfUrl(pdfUrl); + status.setCurrentStep("complete"); + saveExportStatus(contractChatId, status); + + // 임시 데이터 정리 + cleanupTempData(contractChatId); + + return pdfUrl; + } + + /** 초기 상태 생성 */ + private ContractExportStatusDTO createInitialStatus(Long contractChatId) { + // DB에서 계약 정보 조회하여 초기 상태 생성 + log.debug("ContractChatMapper is null? {}", contractChatMapper == null); + + if (contractChatMapper == null) { + log.error("ContractChatMapper is not injected!"); + throw new IllegalStateException("ContractChatMapper is not available"); + } + + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + + if (contractChat == null) { + log.error("Contract chat not found for id: {}", contractChatId); + throw new IllegalArgumentException("Contract chat not found"); + } + + return ContractExportStatusDTO.builder() + .contractChatId(contractChatId) + .ownerId(contractChat.getOwnerId()) + .buyerId(contractChat.getBuyerId()) + .currentStep("preview") + .ownerSignatureCompleted(false) + .buyerSignatureCompleted(false) + .ownerPasswordSet(false) + .buyerPasswordSet(false) + .isCompleted(false) + .lastUpdated(System.currentTimeMillis()) + .build(); + } + + /** 암호 결합 로직 */ + private String combinePasswords(String password1, String password2) { + // 간단한 결합 방식: 두 암호를 연결 + // 실제로는 더 복잡한 암호화 방식 사용 가능 + return password1 + "_" + password2; + } + + /** S3 업로드 */ + private String uploadToS3(Long contractChatId, byte[] pdfData) { + String fileName = "contract_" + contractChatId + "_" + System.currentTimeMillis() + ".pdf"; + + // byte array를 MultipartFile로 변환 + MultipartFile multipartFile = + new MultipartFile() { + @Override + public String getName() { + return fileName; + } + + @Override + public String getOriginalFilename() { + return fileName; + } + + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public boolean isEmpty() { + return pdfData == null || pdfData.length == 0; + } + + @Override + public long getSize() { + return pdfData.length; + } + + @Override + public byte[] getBytes() { + return pdfData; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(pdfData); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), pdfData); + } + }; + + String s3Key = s3Service.uploadFile(multipartFile, fileName); + return s3Service.getFileUrl(s3Key); + } + + /** 임시 데이터 정리 */ + private void cleanupTempData(Long contractChatId) { + // Redis에서 암호 데이터 삭제 + redisTemplate.delete(EXPORT_PASSWORD_KEY + contractChatId + ":owner"); + redisTemplate.delete(EXPORT_PASSWORD_KEY + contractChatId + ":buyer"); + log.info("Cleaned up temporary data for contract {}", contractChatId); + } + + /** 사용자 역할 확인 */ + public String getUserRole(Long contractChatId, Long userId) { + try { + // ContractExportStatusDTO에서 역할 확인 + ContractExportStatusDTO status = getExportStatus(contractChatId); + + if (status != null) { + if (status.getOwnerId() != null && status.getOwnerId().equals(userId)) { + return "owner"; + } else if (status.getBuyerId() != null && status.getBuyerId().equals(userId)) { + return "buyer"; + } + } + + // 상태에 정보가 없으면 DB에서 직접 조회 + log.info( + "Checking user role directly from DB for contractChatId: {}, userId: {}", + contractChatId, + userId); + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + if (contractChat != null) { + if (contractChat.getOwnerId() != null && contractChat.getOwnerId().equals(userId)) { + return "owner"; + } else if (contractChat.getBuyerId() != null + && contractChat.getBuyerId().equals(userId)) { + return "buyer"; + } + } + + log.warn( + "Could not determine user role for contractChatId: {}, userId: {}", + contractChatId, + userId); + return "owner"; // 기본값 + } catch (Exception e) { + log.error("Error determining user role: ", e); + return "owner"; // 에러 시 기본값 + } + } + + /** 임시 PDF 업로드 및 URL 생성 (임차인/임대인만 접근 가능) */ + public String uploadTempPdf(byte[] pdfBytes, String fileName) { + try { + // S3에 임시 파일 업로드 (temp 폴더에 저장) + String tempFileName = "temp/" + fileName; + + // byte array를 MultipartFile로 변환 + MultipartFile multipartFile = + new MultipartFile() { + @Override + public String getName() { + return tempFileName; + } + + @Override + public String getOriginalFilename() { + return tempFileName; + } + + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public boolean isEmpty() { + return pdfBytes == null || pdfBytes.length == 0; + } + + @Override + public long getSize() { + return pdfBytes.length; + } + + @Override + public byte[] getBytes() { + return pdfBytes; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(pdfBytes); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), pdfBytes); + } + }; + + String s3Key = s3Service.uploadFile(multipartFile, tempFileName); + + // S3 Key를 전체 URL로 변환 + String tempUrl = s3Service.getFileUrl(s3Key); + + // Redis에 임시 URL 정보 저장 (1시간 후 만료) + String tempKey = "temp:pdf:" + fileName; + redisTemplate.opsForValue().set(tempKey, tempUrl, 1, TimeUnit.HOURS); + + log.info("Temporary PDF uploaded with S3 key: {}, URL: {}", s3Key, tempUrl); + return tempUrl; + + } catch (Exception e) { + log.error("Failed to upload temporary PDF", e); + throw new RuntimeException("임시 PDF 업로드에 실패했습니다", e); + } + } + + /** 임시 PDF 접근 권한 확인 */ + public boolean canAccessTempPdf(String fileName, Long userId) { + try { + // 파일명에서 계약서 ID 추출 (contract_123_timestamp.pdf 형식) + String[] parts = fileName.split("_"); + if (parts.length < 2) { + return false; + } + + Long contractChatId = Long.parseLong(parts[1]); + + // 해당 계약서의 참여자인지 확인 + String userRole = getUserRole(contractChatId, userId); + return "owner".equals(userRole) || "buyer".equals(userRole); + + } catch (Exception e) { + log.error("Error checking PDF access permission", e); + return false; + } + } + + /** 임시 PDF URL 조회 */ + public String getTempPdfUrl(String fileName) { + String tempKey = "temp:pdf:" + fileName; + return (String) redisTemplate.opsForValue().get(tempKey); + } + + /** 서명이 포함된 최종 PDF 생성 */ + private String generateSignedPdf(Long contractChatId, ContractExportStatusDTO status) + throws Exception { + log.info("Generating signed PDF for contract {} with signatures", contractChatId); + log.info("Status - OwnerId: {}, BuyerId: {}", status.getOwnerId(), status.getBuyerId()); + log.info( + "Owner signatures count: {}", + status.getOwnerSignatures() != null ? status.getOwnerSignatures().size() : 0); + log.info( + "Buyer signatures count: {}", + status.getBuyerSignatures() != null ? status.getBuyerSignatures().size() : 0); + + // 무조건 서명과 동의 여부가 포함된 PDF 생성 + log.info("Creating PDF with signatures and agreement status"); + byte[] signedPdf = createSignedPdfWithSignatures(contractChatId, status); + + if (signedPdf == null || signedPdf.length == 0) { + log.error("Failed to create signed PDF - signedPdf is null or empty"); + throw new RuntimeException("PDF 생성 실패 - 서명이 포함된 PDF를 생성할 수 없습니다"); + } + + log.info("Successfully created signed PDF with size: {} bytes", signedPdf.length); + + // 임대인과 임차인의 생년월일 가져오기 (주민번호 앞자리 사용) + String ownerBirthDate = + contractService.getUserBirthDate(contractChatId, status.getOwnerId(), "owner"); + String buyerBirthDate = + contractService.getUserBirthDate(contractChatId, status.getBuyerId(), "buyer"); + + // 두 생년월일을 조합한 암호화 키 생성 + String combinedKey = ownerBirthDate + "_" + buyerBirthDate; + + // PDF 암호화 + byte[] encryptedPdf = encryptPdfWithCombinedKey(signedPdf, combinedKey); + + // 암호화된 PDF의 해시값 계산 + String pdfHash = calculateHash(encryptedPdf); + + // S3에 암호화된 최종 PDF 업로드 (DB 저장용) + String encryptedFileName = String.format("encrypted/final_contract_%d.pdf", contractChatId); + + // byte array를 MultipartFile로 변환 + MultipartFile encryptedFile = + new MultipartFile() { + @Override + public String getName() { + return encryptedFileName; + } + + @Override + public String getOriginalFilename() { + return encryptedFileName; + } + + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public boolean isEmpty() { + return encryptedPdf == null || encryptedPdf.length == 0; + } + + @Override + public long getSize() { + return encryptedPdf.length; + } + + @Override + public byte[] getBytes() { + return encryptedPdf; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(encryptedPdf); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), encryptedPdf); + } + }; + + String encryptedS3Key = s3Service.uploadFile(encryptedFile, encryptedFileName); + String encryptedS3Url = s3Service.getFileUrl(encryptedS3Key); + + // final_contract 테이블에 암호화된 PDF 정보 저장 + contractService.saveFinalContractToDatabase(contractChatId, encryptedS3Url, pdfHash); + log.info( + "Saved encrypted PDF to database: contractChatId={}, url={}, hash={}", + contractChatId, + encryptedS3Url, + pdfHash); + + // 사용자에게 보여줄 서명된 PDF (암호화 없이) S3에 업로드 - 영구 보관용 + String finalFileName = String.format("final/contract_%d.pdf", contractChatId); + + // byte array를 MultipartFile로 변환 + MultipartFile signedFile = + new MultipartFile() { + @Override + public String getName() { + return finalFileName; + } + + @Override + public String getOriginalFilename() { + return finalFileName; + } + + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public boolean isEmpty() { + return signedPdf == null || signedPdf.length == 0; + } + + @Override + public long getSize() { + return signedPdf.length; + } + + @Override + public byte[] getBytes() { + return signedPdf; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(signedPdf); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), signedPdf); + } + }; + + // 암호화되지 않은 서명 PDF를 S3에 업로드하고 전체 URL 받기 + String signedPdfKey = s3Service.uploadFile(signedFile, finalFileName); + String signedPdfUrl = s3Service.getFileUrl(signedPdfKey); + + log.info( + "Final contract saved - contractChatId: {}, Encrypted URL: {}, Signed PDF URL: {}", + contractChatId, + encryptedS3Url, + signedPdfUrl); + + // 서명 완료 알림 (WebSocket) - 서명된 PDF URL 전송 + broadcastContractCompletion(contractChatId, signedPdfUrl); + + return signedPdfUrl; + } + + /** 상태 업데이트 브로드캐스트 */ + private void broadcastStatusUpdate(Long contractChatId, ContractExportStatusDTO status) { + try { + messagingTemplate.convertAndSend( + "/topic/contract/" + contractChatId + "/export/status", status); + log.info("Status update broadcasted for contract {}", contractChatId); + } catch (Exception e) { + log.error("Failed to broadcast status update", e); + } + } + + /** 계약 완료 알림 브로드캐스트 */ + private void broadcastContractCompletion(Long contractChatId, String finalPdfUrl) { + try { + ContractCompletionMessage message = + ContractCompletionMessage.builder() + .contractChatId(contractChatId) + .finalPdfUrl(finalPdfUrl) + .completedAt(System.currentTimeMillis()) + .message("양측 서명이 완료되어 최종 계약서가 생성되었습니다.") + .build(); + + messagingTemplate.convertAndSend( + "/topic/contract/" + contractChatId + "/completion", message); + + log.info("Contract completion broadcasted for contract {}", contractChatId); + } catch (Exception e) { + log.error("Failed to broadcast contract completion", e); + } + } + + // 계약 완료 메시지 클래스 + @lombok.Builder + @lombok.Data + private static class ContractCompletionMessage { + private Long contractChatId; + private String finalPdfUrl; + private long completedAt; + private String message; + } + + /** 서명된 PDF를 사용자 역할에 맞는 암호로 보호하여 반환 */ + public byte[] getSignedPdfWithPassword(Long contractChatId, Long userId) throws Exception { + ContractExportStatusDTO status = getExportStatus(contractChatId); + + // 완료된 PDF URL 확인 + if (status == null || !status.isCompleted() || status.getFinalPdfUrl() == null) { + throw new IllegalStateException("최종 계약서가 아직 생성되지 않았습니다."); + } + + // 사용자 역할 확인 + String userRole = getUserRole(contractChatId, userId); + + // 역할에 따른 생년월일 가져오기 (YYMMDD 형식) + String birthDate = contractService.getUserBirthDate(contractChatId, userId, userRole); + + // S3에서 최종 PDF 다운로드 + String s3Key = extractS3KeyFromUrl(status.getFinalPdfUrl()); + InputStream inputStream = s3Service.downloadFile(s3Key); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + byte[] pdfData = outputStream.toByteArray(); + + // 생년월일로 암호화된 PDF 생성 + return encryptPdfWithPassword(pdfData, birthDate); + } + + /** S3 URL에서 키 추출 */ + private String extractS3KeyFromUrl(String url) { + // URL에서 S3 키 추출 로직 + // 예: https://bucket.s3.amazonaws.com/path/to/file.pdf -> path/to/file.pdf + if (url.contains(".amazonaws.com/")) { + return url.substring(url.lastIndexOf(".com/") + 5); + } + return url; + } + + /** PDF를 암호로 보호 */ + private byte[] encryptPdfWithPassword(byte[] pdfData, String password) throws Exception { + // iText7를 사용한 PDF 암호화 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + com.itextpdf.kernel.pdf.PdfReader reader = + new com.itextpdf.kernel.pdf.PdfReader( + new java.io.ByteArrayInputStream(pdfData)); + com.itextpdf.kernel.pdf.PdfWriter writer = + new com.itextpdf.kernel.pdf.PdfWriter( + baos, + new com.itextpdf.kernel.pdf.WriterProperties() + .setStandardEncryption( + password.getBytes(), + password.getBytes(), + com.itextpdf.kernel.pdf.EncryptionConstants + .ALLOW_PRINTING + | com.itextpdf.kernel.pdf.EncryptionConstants + .ALLOW_COPY, + com.itextpdf.kernel.pdf.EncryptionConstants + .ENCRYPTION_AES_128)); + + com.itextpdf.kernel.pdf.PdfDocument pdfDoc = + new com.itextpdf.kernel.pdf.PdfDocument(reader, writer); + pdfDoc.close(); + + } catch (Exception e) { + log.error("Failed to encrypt PDF", e); + throw e; + } + + return baos.toByteArray(); + } + + /** 조합된 키로 PDF 암호화 */ + private byte[] encryptPdfWithCombinedKey(byte[] pdfData, String combinedKey) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + com.itextpdf.kernel.pdf.PdfReader reader = + new com.itextpdf.kernel.pdf.PdfReader( + new java.io.ByteArrayInputStream(pdfData)); + com.itextpdf.kernel.pdf.PdfWriter writer = + new com.itextpdf.kernel.pdf.PdfWriter( + baos, + new com.itextpdf.kernel.pdf.WriterProperties() + .setStandardEncryption( + combinedKey.getBytes(), + combinedKey.getBytes(), + com.itextpdf.kernel.pdf.EncryptionConstants + .ALLOW_PRINTING + | com.itextpdf.kernel.pdf.EncryptionConstants + .ALLOW_COPY, + com.itextpdf.kernel.pdf.EncryptionConstants + .ENCRYPTION_AES_256)); + + com.itextpdf.kernel.pdf.PdfDocument pdfDoc = + new com.itextpdf.kernel.pdf.PdfDocument(reader, writer); + pdfDoc.close(); + + } catch (Exception e) { + log.error("Failed to encrypt PDF with combined key", e); + throw e; + } + + return baos.toByteArray(); + } + + /** PDF 해시값 계산 */ + private String calculateHash(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + log.error("Failed to calculate hash", e); + throw new RuntimeException("Hash calculation failed", e); + } + } + + /** 서명이 포함된 PDF 직접 생성 - AI 서버 사용 */ + private byte[] createSignedPdfWithSignatures( + Long contractChatId, ContractExportStatusDTO status) { + try { + log.info( + "Creating signed PDF with signatures using AI server for contract {}", + contractChatId); + + // 임차인 서명 확인 로그 추가 + if (status.getBuyerSignatures() != null && !status.getBuyerSignatures().isEmpty()) { + log.info( + "Buyer signatures present: {} signatures", + status.getBuyerSignatures().size()); + for (int i = 0; i < status.getBuyerSignatures().size(); i++) { + String sig = status.getBuyerSignatures().get(i); + if (sig != null) { + log.info("Buyer signature {} length: {}", i + 1, sig.length()); + } + } + } else { + log.warn("No buyer signatures found in status!"); + } + + // AI 서버를 사용하여 전체 데이터 + 동의 여부 + 서명 이미지로 PDF 생성 + byte[] signedPdf = generatePdfWithAiServer(contractChatId, status); + + if (signedPdf == null || signedPdf.length == 0) { + log.error("Failed to generate signed PDF from AI server, trying fallback"); + // AI 서버 실패 시 fallback PDF 생성 + signedPdf = createFallbackPdf(contractChatId, status); + } + + if (signedPdf == null || signedPdf.length == 0) { + log.error("Failed to generate any PDF including fallback"); + throw new RuntimeException("AI 서버 및 fallback PDF 생성 모두 실패"); + } + + log.info("Signed PDF generated successfully with size: {} bytes", signedPdf.length); + return signedPdf; + + } catch (Exception e) { + log.error("Failed to create signed PDF with signatures: ", e); + log.error("Error type: {}", e.getClass().getName()); + log.error("Error message: {}", e.getMessage()); + if (e.getCause() != null) { + log.error("Cause: {}", e.getCause().getMessage()); + } + // 에러 발생 시 null 반환하여 실패를 명확히 함 + throw new RuntimeException("PDF 생성 실패: " + e.getMessage(), e); + } + } + + /** AI 서버를 사용하여 전체 데이터 + 동의 여부 + 서명 이미지로 PDF 생성 */ + private byte[] generatePdfWithAiServer(Long contractChatId, ContractExportStatusDTO status) { + try { + log.info( + "Generating PDF with AI server for contract {} with full data + signatures", + contractChatId); + + // 서명 데이터 상세 로깅 + log.info("=== Signature Data Analysis ==="); + log.info( + "Owner signatures count: {}", + status.getOwnerSignatures() != null ? status.getOwnerSignatures().size() : 0); + if (status.getOwnerSignatures() != null) { + for (int i = 0; i < status.getOwnerSignatures().size(); i++) { + String sig = status.getOwnerSignatures().get(i); + log.info( + "Owner signature {}: {} (length: {})", + i + 1, + sig != null ? "present" : "null", + sig != null ? sig.length() : 0); + } + } + + log.info( + "Buyer signatures count: {}", + status.getBuyerSignatures() != null ? status.getBuyerSignatures().size() : 0); + if (status.getBuyerSignatures() != null) { + for (int i = 0; i < status.getBuyerSignatures().size(); i++) { + String sig = status.getBuyerSignatures().get(i); + log.info( + "Buyer signature {}: {} (length: {})", + i + 1, + sig != null ? "present" : "null", + sig != null ? sig.length() : 0); + if (sig != null && sig.length() > 0) { + log.info( + "Buyer signature {} preview: {}", + i + 1, + sig.substring(0, Math.min(50, sig.length()))); + } + } + } else { + log.error("❌ Buyer signatures list is NULL!"); + } + log.info("==============================="); + + // ContractService의 generateContractWithSignatures 메서드 사용 + // 사용자 ID는 임대인 ID를 사용 (권한 확인용) + byte[] pdfBytes = + contractService.generateContractWithSignatures( + contractChatId, + status.getOwnerId(), // userId for validation + status.getOwnerSignatures(), + status.getBuyerSignatures(), + status.isOwnerHasTaxArrears(), + status.isOwnerHasPriorFixedDate(), + status.isOwnerMediationAgree(), + status.isBuyerMediationAgree()); + + if (pdfBytes != null && pdfBytes.length > 0) { + log.info("AI server PDF generation successful, size: {} bytes", pdfBytes.length); + return pdfBytes; + } else { + log.error("AI server returned empty or null PDF data"); + return null; + } + + } catch (Exception e) { + log.error("Failed to generate PDF with AI server: ", e); + return null; + } + } + + /** Fallback PDF 생성 (AI 서버 실패 시) */ + private byte[] createFallbackPdf(Long contractChatId, ContractExportStatusDTO status) { + try { + log.info("Creating fallback PDF for contract {}", contractChatId); + + // 기존 계약서 PDF 가져오기 시도 + try { + // MongoDB에서 기존 계약서 데이터 조회 + byte[] existingPdf = contractService.getExistingContractPdf(contractChatId); + if (existingPdf != null && existingPdf.length > 0) { + log.info("Using existing contract PDF as fallback"); + return existingPdf; + } + } catch (Exception e) { + log.warn("Could not retrieve existing PDF: ", e); + } + + // AI 서버를 사용한 fallback PDF 생성 시도 + log.info("Attempting to generate fallback PDF using AI server"); + try { + // AI 서버에 서명 없는 버전으로 요청 (기본 계약서 생성) + byte[] fallbackPdf = + contractService.startContractExport(contractChatId, status.getOwnerId()); + if (fallbackPdf != null && fallbackPdf.length > 0) { + log.info( + "Fallback PDF generated using AI server, size: {} bytes", + fallbackPdf.length); + return fallbackPdf; + } + } catch (Exception aiException) { + log.warn("AI server fallback also failed: ", aiException); + } + + // 최후의 수단으로 null 반환 (완전한 실패) + log.error("All PDF generation methods failed for contract {}", contractChatId); + return null; + + } catch (Exception e) { + log.error("Failed to create fallback PDF", e); + return null; + } + } +} 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 03f334c7..504a7024 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractService.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractService.java @@ -1,6 +1,11 @@ package org.scoula.domain.contract.service; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + import org.scoula.domain.contract.dto.*; +import org.springframework.web.multipart.MultipartFile; public interface ContractService { @@ -123,4 +128,135 @@ public interface ContractService { * @param userId 유저 아이디 @Parma step 계약서 단계 */ Void sendStep4(Long contractChatId, Long userId); + + // ============================================ + /** + * 계약서 내보내기 시작 - AI 서버에서 초기 PDF 생성 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + * @return PDF 바이트 배열 + */ + byte[] startContractExport(Long contractChatId, Long userId); + + /** + * 서명이 포함된 계약서 PDF 생성 + * + * @param contractChatId 계약 채팅 ID + * @param userId 사용자 ID + * @param ownerSignatures 임대인 서명 목록 + * @param buyerSignatures 임차인 서명 목록 + * @param ownerHasTaxArrears 임대인 세금체납 여부 + * @param ownerHasPriorFixedDate 임대인 선순위확정일자 여부 + * @param ownerMediationAgree 임대인 조정 동의 여부 + * @param buyerMediationAgree 임차인 조정 동의 여부 + * @return 서명이 포함된 PDF 바이트 배열 + */ + byte[] generateContractWithSignatures( + Long contractChatId, + Long userId, + java.util.List ownerSignatures, + java.util.List buyerSignatures, + boolean ownerHasTaxArrears, + boolean ownerHasPriorFixedDate, + boolean ownerMediationAgree, + boolean buyerMediationAgree); + + /** + * 최종 계약서 작성하기 PDF -> AI + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + MultipartFile finalContractPDF(Long contractChatId, Long userId); + + /** + * 전자서명 파일 암호화 후 S3에 저장 (암호화 형식이 다름) + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + Boolean saveSignature( + Long contractChatId, + Long userId, + SaveSignatureDTO signatureDTO, + List imgFiles) + throws Exception; + + /** + * 최종계약서 S3에 저장 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + byte[] saveFinalContract(Long contractChatId, Long userId, ContractPasswordDTO dto); + + /** + * 최종 계약서를 불러와서 보내주기 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + byte[] selectContractPDF(Long contractChatId, Long userId, ContractPasswordDTO dto); + + // /** + // * 계약서 PDF 파일 암호화 후 S3에 저장 (암호화 형식이 다름) + // * + // * @param contractChatId 채팅방 아이디 + // * @param userId 유저 아이디 @Parma step 계약서 단계 @Param dto 실제 계약서에 있는 내역들 + // */ + // Void saveContractPDF(Long contractChatId, Long userId, FinalContractDTO dto); + + // /** + // * 전자서명 다운로드 (복호화하기) + // * + // * @param contractChatId 채팅방 아이디 + // * @param userId 유저 아이디 @Parma step 계약서 단계 @Param dto 실제 계약서에 있는 내역들 + // */ + // Void selectSignaturePDF(Long contractChatId, Long userId, HttpServletResponse response); + + /** + * 계약서 PDF 파일 다운로드/인쇄하기 -> 프론트에 보내기 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 @Parma step 계약서 단계 + */ + Void selectContractPDF( + Long contractChatId, Long userId, HttpServletResponse response, FindContractDTO dto) + throws Exception; + + /** + * 계약서 PDF를 이메일로 전송 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 @Parma step 계약서 단계 + */ + Void sendContractPDF(Long contractChatId, Long userId, FindContractDTO dto) throws Exception; + + /** + * 사용자의 생년월일 가져오기 + * + * @param contractChatId 계약 채팅 ID + * @param userId 사용자 ID + * @param userRole 사용자 역할 (owner/buyer) + * @return 생년월일 (YYMMDD 형식) + */ + String getUserBirthDate(Long contractChatId, Long userId, String userRole); + + /** + * 최종 계약서를 데이터베이스에 저장 + * + * @param contractChatId 계약 채팅 ID + * @param s3Url S3 URL (전체 URL) + * @param pdfHash PDF 해시값 + */ + void saveFinalContractToDatabase(Long contractChatId, String s3Url, String pdfHash); + + /** + * 기존 계약서 PDF 가져오기 (fallback용) + * + * @param contractChatId 계약 채팅 ID + * @return PDF 바이트 배열 + */ + byte[] getExistingContractPdf(Long contractChatId); } 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 91828df4..0b7f0cf3 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java @@ -1,25 +1,35 @@ package org.scoula.domain.contract.service; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.util.Base64; 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; +import org.scoula.domain.fraud.mapper.FraudRiskMapper; +import org.scoula.domain.fraud.vo.BuildingDocumentVO; +import org.scoula.domain.fraud.vo.RiskCheckVO; import org.scoula.domain.contract.document.ContractMongoDocument; import org.scoula.domain.contract.dto.*; +import org.scoula.domain.contract.enums.SignedType; 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.contract.vo.ElectronicSignature; +import org.scoula.domain.contract.vo.FinalContract; import org.scoula.domain.precontract.enums.ContractDuration; +import org.scoula.domain.precontract.enums.RentType; 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.service.IdentityVerificationServiceImpl; import org.scoula.domain.precontract.vo.IdentityVerificationInfoVO; +import org.scoula.global.common.dto.FileWithHashDto; import org.scoula.global.common.exception.BusinessException; +import org.scoula.global.common.service.EncryptionService; +import org.scoula.global.common.util.*; import org.scoula.global.email.service.EmailServiceImpl; import org.scoula.global.file.service.S3ServiceImpl; import org.springframework.beans.factory.annotation.Value; @@ -28,11 +38,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; -import java.time.Duration; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -47,81 +64,98 @@ public class ContractServiceImpl implements ContractService { private final ContractMapper contractMapper; private final TenantPreContractMapper tenantMapper; private final ObjectMapper objectMapper = new ObjectMapper(); + private final RestTemplate restTemplate; + private final FraudRiskMapper fraudRiskMapper; + private final EncryptionService encryptionService; - private final RedisTemplate stringRedisTemplate; - private final S3ServiceImpl s3Service; - private final EmailServiceImpl emailService; + private final RedisTemplate stringRedisTemplate; + private final S3ServiceImpl s3Service; + private final EmailServiceImpl emailService; + private final AesCryptoUtil aesCryptoUtil; + private final ImgAesCryptoUtil imgAesCryptoUtil; + private final NumberFormatUtil numberFormatUtil; +// private static final String ALGORITHM = "AES"; +// private static final String TRANSFORMATION = "AES"; +// private static final String SECRET_KEY = "mySuperSecretKey"; // 16글자 (128bit) ==> 환경변수에 넣기 - /** {@inheritDoc} */ - @Override - public Void saveContractMongo(Long contractChatId, Long userId) { - // userId 검증 - validateIsOwner(contractChatId, userId); + @Value("${ai.server.url:http://localhost:8000}") + private String aiServerUrl; - // 이미 생성된 계약 문서가 있으면 저장 대신 안내 메시지 전송 후 종료 - ContractMongoDocument existing = repository.getContract(contractChatId); - if (existing != null) { - contractChatService.AiMessage(contractChatId, " 이미 생성된 계약서가 있어요.\n" + "기존 계약서를 불러올게요."); - return null; - } - // 계약서에 들어갈 내용들을 mapper로 가져오기 - ContractDTO dto = contractMapper.getContract(contractChatId); - - // 계약 끝나는 기간 - String durationStr = contractMapper.getDuration(contractChatId); - ContractDuration duration = ContractDuration.valueOf(durationStr); - - LocalDate startDate = dto.getContractStartDate(); - - LocalDate contractEndDate = null; - if (duration == ContractDuration.YEAR_1) { - contractEndDate = startDate.plusYears(1); - } else if (duration == ContractDuration.YEAR_2) { - contractEndDate = startDate.plusYears(2); - } else if (duration == ContractDuration.YEAR_3) { - contractEndDate = startDate.plusYears(3); - } else if (duration == ContractDuration.YEAR_4) { - contractEndDate = startDate.plusYears(4); - } else if (duration == ContractDuration.YEAR_5) { - contractEndDate = startDate.plusYears(5); - } + /** + * {@inheritDoc} + */ + @Override + public Void saveContractMongo(Long contractChatId, Long userId) { + // userId 검증 + validateIsOwner(contractChatId, userId); - // mongoDB에 contract 도큐멘트를 만들어서 저장한다. - ContractMongoDocument document = repository.saveContractMongo(dto, contractEndDate); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_INSERT); - } - return null; - } + // 이미 생성된 계약 문서가 있으면 저장 대신 안내 메시지 전송 후 종료 + ContractMongoDocument existing = repository.getContract(contractChatId); + if (existing != null) { + contractChatService.AiMessage(contractChatId, " 이미 생성된 계약서가 있어요.\n" + "기존 계약서를 불러올게요."); + return null; + } - /** {@inheritDoc} */ - // 계약서 조회하기 - @Override - public ContractDTO getContract(Long contractChatId, Long userId) { + // 계약서에 들어갈 내용들을 mapper로 가져오기 + ContractDTO dto = contractMapper.getContract(contractChatId); + + // 계약 끝나는 기간 + String durationStr = contractMapper.getDuration(contractChatId); + ContractDuration duration = ContractDuration.valueOf(durationStr); + + LocalDate startDate = dto.getContractStartDate(); + + LocalDate contractEndDate = null; + if (duration == ContractDuration.YEAR_1) { + contractEndDate = startDate.plusYears(1); + } else if (duration == ContractDuration.YEAR_2) { + contractEndDate = startDate.plusYears(2); + } else if (duration == ContractDuration.YEAR_3) { + contractEndDate = startDate.plusYears(3); + } else if (duration == ContractDuration.YEAR_4) { + contractEndDate = startDate.plusYears(4); + } else if (duration == ContractDuration.YEAR_5) { + contractEndDate = startDate.plusYears(5); + } - // userId 검증 - validateUserId(contractChatId, userId); + // mongoDB에 contract 도큐멘트를 만들어서 저장한다. + ContractMongoDocument document = repository.saveContractMongo(dto, contractEndDate); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_INSERT); + } + return null; + } - // id로 Repository에서 값을 찾는다 - ContractMongoDocument document = repository.getContract(contractChatId); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_GET); - } + /** + * {@inheritDoc} + */ + // 계약서 조회하기 + @Override + public ContractDTO getContract(Long contractChatId, Long userId) { - Long ownerContractId = contractMapper.getOwnerId(contractChatId); - Long buyerContractId = contractMapper.getBuyerId(contractChatId); + // userId 검증 + validateUserId(contractChatId, userId); - IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); - IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + // id로 Repository에서 값을 찾는다 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_GET); + } - // 찾은 값을 Dto에 넣고 반환하기 - ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); - return dto; - } + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + // 찾은 값을 Dto에 넣고 반환하기 + ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + + return dto; + } @Override // 해당 스텝 메세지 & 다음 단계로 넘어가는지 @@ -560,9 +594,1397 @@ public Void sendStep4(Long contractChatId, Long userId) { return null; } - // --------------------------------------- - // Userid 검증 - public void validateUserId(Long contractChatId, Long userId) { + // --------------------------------------- + // ======================================================================== + + // 금액을 한글로 변환하는 헬퍼 메서드 + private String convertToKoreanWon(int amount) { + // NumberFormatUtil이 있다면 그것을 사용하고, 없다면 간단한 변환 + try { + return numberFormatUtil.toKoreanNumber(amount); + } catch (Exception e) { + // 기본 변환 로직 + return String.format("%,d원", amount); + } + } + + // 계약서 내보내기 시작 - AI 서버에서 초기 PDF 생성 + @Override + @Transactional + public byte[] startContractExport(Long contractChatId, Long userId) { + log.info("Starting contract export for contractChatId: {}, userId: {}", contractChatId, userId); + + try { + // userId 인증 + validateUserId(contractChatId, userId); + + // MongoDB에서 계약 정보 조회 + log.info("Fetching contract from MongoDB..."); + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + log.error("Contract not found in MongoDB for contractChatId: {}", contractChatId); + throw new BusinessException(ContractException.CONTRACT_NOT_FOUND); + } + log.info("MongoDB document found"); + + // DB에서 필요한 정보 조회 + log.info("Fetching contract data from DB..."); + DBFinalContractDTO dbDTO = contractMapper.selectFinalContractPDF(contractChatId); + log.info("DB data fetched"); + + // 복호화 + log.info("Getting owner and buyer IDs..."); + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + log.info("OwnerID: {}, BuyerID: {}", ownerContractId, buyerContractId); + + log.info("Getting identity verification info..."); + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + log.info("Identity verification info retrieved"); + + // ContractChat 정보 조회하여 homeId 가져오기 + log.info("Getting contract chat info..."); + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + BuildingDocumentVO buildingDocument = null; + + if (contractChat != null && contractChat.getHomeId() != null) { + log.info("HomeId found: {}", contractChat.getHomeId()); + // homeId로 가장 최근의 위험도 체크 조회 + RiskCheckVO latestRiskCheck = fraudRiskMapper.selectLatestRiskCheckByHomeId(contractChat.getHomeId()); + + if (latestRiskCheck != null) { + log.info("Risk check found, getting building document..."); + // 위험도 체크 ID로 건축물대장 정보 조회 + buildingDocument = fraudRiskMapper.selectBuildingDocumentByRiskCheckId(latestRiskCheck.getRiskckId()); + } + } + + // SaveFinalContractDTO 생성 + log.info("Building SaveFinalContractDTO..."); + // homeId 정보 가져오기 + ContractChat contractChatForHome = contractChatMapper.findByContractChatId(contractChatId); + Long homeId = contractChatForHome != null ? contractChatForHome.getHomeId() : null; + SaveFinalContractDTO dto = buildSaveFinalContractDTO(document, dbDTO, ownerVO, buyerVO, buildingDocument, homeId); + log.info("DTO built successfully"); + + // AI 서버에 PDF 생성 요청 + log.info("Requesting PDF generation from AI server at: {}", aiServerUrl); + // DTO가 null이 아닌지 확인 + if (dto == null) { + log.error("SaveFinalContractDTO가 null입니다."); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED); + } + + // 필수 필드 확인을 위한 디버깅 + log.info("DTO 필드 확인 - leaseType: {}, ownerNickname: {}, buyerNickname: {}, addr1: {}", + dto.getLeaseType(), dto.getOwnerNickname(), dto.getBuyerNickname(), dto.getAddr1()); + + // 서명 이미지를 빈 문자열로 초기화 (서명 없는 미리보기용) + dto.setOwnerSign1Base64(""); + dto.setOwnerSign2Base64(""); + dto.setOwnerSign3Base64(""); + dto.setBuyerSignBase64(""); + + // JSON으로 전송 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Accept", "application/pdf"); + + // DTO를 JSON 문자열로 변환하여 로그 출력 + ObjectMapper objectMapper = new ObjectMapper(); + String jsonPayload = objectMapper.writeValueAsString(dto); + log.info("AI 서버로 전송할 JSON 데이터: {}", jsonPayload); + + HttpEntity request = new HttpEntity<>(dto, headers); + + // PDF 바이트 배열로 직접 응답 받기 + ResponseEntity response = restTemplate.exchange( + aiServerUrl + "/api/contract/generate-json", + HttpMethod.POST, + request, + byte[].class + ); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + byte[] pdfData = response.getBody(); + + // PDF 데이터 크기 확인 + if (pdfData.length == 0) { + log.error("AI 서버에서 빈 응답을 받았습니다"); + return generateFallbackPdf(contractChatId, dto); + } + + // PDF 헤더 확인 (%PDF) + if (pdfData.length > 4) { + String header = new String(pdfData, 0, 4); + if (!header.startsWith("%PDF")) { + // PDF가 아닌 경우, 텍스트 응답인지 확인 + String textResponse = new String(pdfData, 0, Math.min(1000, pdfData.length)); + log.error("AI 서버에서 PDF가 아닌 응답을 받았습니다. 처음 1000 바이트: {}", textResponse); + + // URL 패턴인지 확인 (uploads/ 또는 http로 시작) + if (textResponse.contains("uploads/") || textResponse.startsWith("http")) { + log.info("응답이 URL 형식입니다. URL에서 PDF 다운로드 시도: {}", textResponse.trim()); + + try { + String fullUrl = textResponse.trim(); + if (!fullUrl.startsWith("http")) { + // 상대 경로인 경우 AI 서버 URL과 조합 + fullUrl = aiServerUrl + "/" + fullUrl; + } + + ResponseEntity pdfResponse = restTemplate.getForEntity(fullUrl, byte[].class); + + if (pdfResponse.getStatusCode() == HttpStatus.OK && pdfResponse.getBody() != null) { + byte[] downloadedPdf = pdfResponse.getBody(); + + // 다운로드한 파일이 PDF인지 확인 + if (downloadedPdf.length > 4) { + String pdfHeader = new String(downloadedPdf, 0, 4); + if (pdfHeader.startsWith("%PDF")) { + log.info("PDF 다운로드 성공, 크기: {} bytes", downloadedPdf.length); + return downloadedPdf; + } + } + } + } catch (Exception e) { + log.error("URL에서 PDF 다운로드 실패: ", e); + } + } + + // Fallback으로 기본 PDF 생성 + return generateFallbackPdf(contractChatId, dto); + } + } + + log.info("PDF 생성 성공, 크기: {} bytes", pdfData.length); + return pdfData; + } else { + log.error("AI 서버 응답 실패: Status={}", response.getStatusCode()); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED); + } + } catch (BusinessException be) { + log.error("Business exception in PDF generation: ", be); + throw be; + } catch (Exception e) { + log.error("Unexpected error in PDF generation: ", e); + log.error("Error type: {}", e.getClass().getName()); + log.error("Error message: {}", e.getMessage()); + if (e.getCause() != null) { + log.error("Cause: {}", e.getCause().getMessage()); + } + + // Fallback PDF 생성 시도 + try { + log.info("Attempting to generate fallback PDF due to error"); + return generateFallbackPdf(contractChatId, null); + } catch (Exception fallbackError) { + log.error("Fallback PDF generation also failed: ", fallbackError); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED); + } + } + } + + // Fallback PDF 생성 메서드 + private byte[] generateFallbackPdf(Long contractChatId, SaveFinalContractDTO dto) { + log.info("Fallback PDF 생성 시작 - contractChatId: {}", contractChatId); + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + com.itextpdf.kernel.pdf.PdfWriter writer = new com.itextpdf.kernel.pdf.PdfWriter(baos); + com.itextpdf.kernel.pdf.PdfDocument pdfDoc = new com.itextpdf.kernel.pdf.PdfDocument(writer); + com.itextpdf.layout.Document document = new com.itextpdf.layout.Document(pdfDoc); + + // 한글 폰트 설정 (기본 폰트 사용) + com.itextpdf.kernel.font.PdfFont font = com.itextpdf.kernel.font.PdfFontFactory.createFont( + "Helvetica", "Identity-H", com.itextpdf.kernel.font.PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + + // 제목 + document.add(new com.itextpdf.layout.element.Paragraph("부동산 임대차 계약서") + .setFont(font) + .setFontSize(20) + .setBold() + .setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)); + + document.add(new com.itextpdf.layout.element.Paragraph("")); + + // 계약 정보 + document.add(new com.itextpdf.layout.element.Paragraph("계약 번호: " + contractChatId) + .setFont(font)); + + if (dto != null) { + document.add(new com.itextpdf.layout.element.Paragraph("임대 유형: " + + (dto.getLeaseType() ? "전세" : "월세")) + .setFont(font)); + + document.add(new com.itextpdf.layout.element.Paragraph("")); + + // 당사자 정보 + document.add(new com.itextpdf.layout.element.Paragraph("[ 임대인 ]") + .setFont(font) + .setBold()); + document.add(new com.itextpdf.layout.element.Paragraph("성명: " + + (dto.getOwnerNickname() != null ? dto.getOwnerNickname() : "임대인")) + .setFont(font)); + + document.add(new com.itextpdf.layout.element.Paragraph("")); + + document.add(new com.itextpdf.layout.element.Paragraph("[ 임차인 ]") + .setFont(font) + .setBold()); + document.add(new com.itextpdf.layout.element.Paragraph("성명: " + + (dto.getBuyerNickname() != null ? dto.getBuyerNickname() : "임차인")) + .setFont(font)); + + document.add(new com.itextpdf.layout.element.Paragraph("")); + + // 부동산 정보 + document.add(new com.itextpdf.layout.element.Paragraph("[ 부동산 정보 ]") + .setFont(font) + .setBold()); + document.add(new com.itextpdf.layout.element.Paragraph("주소: " + + dto.getAddr1() + " " + (dto.getAddr2() != null ? dto.getAddr2() : "")) + .setFont(font)); + } else { + // dto가 null인 경우 기본 정보만 표시 + document.add(new com.itextpdf.layout.element.Paragraph("")); + document.add(new com.itextpdf.layout.element.Paragraph("※ 계약 정보를 불러올 수 없습니다.") + .setFont(font) + .setItalic()); + } + + document.add(new com.itextpdf.layout.element.Paragraph("")); + + // 안내 메시지 + document.add(new com.itextpdf.layout.element.Paragraph( + "※ 이 문서는 임시 생성된 계약서입니다. AI 서버 연결 문제로 정식 계약서를 생성할 수 없습니다.") + .setFont(font) + .setFontSize(10) + .setItalic()); + + document.add(new com.itextpdf.layout.element.Paragraph("생성 일시: " + new java.util.Date()) + .setFont(font) + .setFontSize(10)); + + document.close(); + + byte[] pdfBytes = baos.toByteArray(); + log.info("Fallback PDF 생성 완료, 크기: {} bytes", pdfBytes.length); + return pdfBytes; + + } catch (Exception e) { + log.error("Fallback PDF 생성 실패: ", e); + // 최후의 수단으로 빈 PDF 반환 + return new byte[0]; + } + } + + // SaveFinalContractDTO 빌드 헬퍼 메서드 + private SaveFinalContractDTO buildSaveFinalContractDTO( + ContractMongoDocument document, + DBFinalContractDTO dbDTO, + IdentityVerificationInfoVO ownerVO, + IdentityVerificationInfoVO buyerVO, + BuildingDocumentVO buildingDocument, + Long homeId) { + + SaveFinalContractDTO dto = new SaveFinalContractDTO(); + + // 임대 유형 (전세: true, 월세: false) + dto.setLeaseType(RentType.JEONSE.name().equals(dbDTO.getLeaseType())); + + // 임대인/임차인 정보 + dto.setOwnerNickname(ownerVO.getName()); + dto.setBuyerNickname(buyerVO.getName()); + + // 주소 정보 - 여러 소스에서 가져오기 (우선순위: DB -> MongoDB -> BuildingDocument) + String addr1 = dbDTO.getHomeAddr1(); // DB에서 먼저 가져오기 + String addr2 = dbDTO.getHomeAddr2(); + + log.info("Address from DB - addr1: '{}', addr2: '{}'", addr1, addr2); + + // DB의 주소가 비어있으면 MongoDB document에서 가져오기 + if (addr1 == null || addr1.trim().isEmpty()) { + addr1 = document.getHomeAddr1(); + addr2 = document.getHomeAddr2(); + log.info("Address from MongoDB - addr1: '{}', addr2: '{}'", addr1, addr2); + } + + // MongoDB document의 주소도 비어있으면 BuildingDocument에서 가져오기 + if ((addr1 == null || addr1.trim().isEmpty()) && buildingDocument != null) { + String roadAddress = buildingDocument.getRoadAddress(); + log.info("BuildingDocument roadAddress: '{}'", roadAddress); + if (roadAddress != null && !roadAddress.trim().isEmpty()) { + addr1 = roadAddress; + log.info("Using address from BuildingDocument: {}", addr1); + } + } + + // 여전히 비어있으면 기본값 설정 + if (addr1 == null || addr1.trim().isEmpty()) { + addr1 = "주소 정보 없음"; + log.warn("No address information found for contract {}, using default", document.getContractChatId()); + } + + dto.setAddr1(addr1); + dto.setAddr2(addr2 != null ? addr2 : ""); + + log.info("Final address set - addr1: '{}', addr2: '{}'", dto.getAddr1(), dto.getAddr2()); + + // 건물 정보 + dto.setLandCategory(dbDTO.getLandCategory()); + dto.setArea(String.valueOf(dbDTO.getArea())); + dto.setBuildingStructure(dbDTO.getBuildingStructure() != null ? dbDTO.getBuildingStructure() : "철근콘크리트 구조"); + + // 건축물대장 정보가 있으면 사용, 없으면 기본값 + if (buildingDocument != null) { + dto.setPurpose(buildingDocument.getPurpose() != null ? buildingDocument.getPurpose() : "주택"); + dto.setTotalFloorArea(buildingDocument.getTotalFloorArea() != null ? + buildingDocument.getTotalFloorArea().toString() : "100"); + } else { + dto.setPurpose("주택"); // 기본값 + dto.setTotalFloorArea("100"); // 기본값 + } + + dto.setSupplyArea(String.valueOf(document.getExclusiveArea())); + + // 체크박스 + dto.setHasTaxArrears(false); // 초기값 + dto.setHasPriorFixedDate(false); // 초기값 + + // 금액 정보 + dto.setTextDepositPrice(convertToKoreanWon(document.getDepositPrice())); + dto.setDepositPrice(String.valueOf(document.getDepositPrice())); + dto.setMonthlyRent(String.valueOf(document.getMonthlyRent())); + dto.setPaymentDueDay(String.valueOf(dbDTO.getPaymentDueDay())); + dto.setBankAccount(dbDTO.getBankAccount()); + dto.setTextMaintenanceFee(convertToKoreanWon(document.getMaintenanceFee())); + dto.setMaintenanceFee(String.valueOf(document.getMaintenanceFee())); + + // 날짜 정보 + LocalDate moveInDate = dbDTO.getExpectedMoveInDate(); + // 계약 기간을 사용하여 퇴거 날짜 계산 + LocalDate moveOutDate = moveInDate.plusYears(dbDTO.getContractDuration().getYears()); + LocalDate contractDate = LocalDate.now(); + + dto.setExpectedMoveInYear(String.valueOf(moveInDate.getYear())); + dto.setExpectedMoveInMonth(String.valueOf(moveInDate.getMonthValue())); + dto.setExpectedMoveInDay(String.valueOf(moveInDate.getDayOfMonth())); + dto.setExpectedMoveOutYear(String.valueOf(moveOutDate.getYear())); + dto.setExpectedMoveOutMonth(String.valueOf(moveOutDate.getMonthValue())); + dto.setExpectedMoveOutDay(String.valueOf(moveOutDate.getDayOfMonth())); + dto.setContractDateYear(String.valueOf(contractDate.getYear())); + dto.setContractDateMonth(String.valueOf(contractDate.getMonthValue())); + dto.setContractDateDay(String.valueOf(contractDate.getDayOfMonth())); + + // 개인정보 + dto.setOwnerAddr(ownerVO.getAddr1() + " " + ownerVO.getAddr2()); + dto.setOwnerSsn(dbDTO.getOwnerSsnFront() + "-" + aesCryptoUtil.decrypt(dbDTO.getOwnerSsnBack())); + dto.setOwnerPhoneNumber(ownerVO.getPhoneNumber()); + dto.setBuyerAddr(buyerVO.getAddr1() + " " + buyerVO.getAddr2()); + dto.setBuyerSsn(dbDTO.getBuyerSsnFront() + "-" + aesCryptoUtil.decrypt(dbDTO.getBuyerSsnBack())); + dto.setBuyerPhoneNumber(buyerVO.getPhoneNumber()); + + // 특약사항 - SpecialContract 객체 리스트를 String 리스트로 변환 + if (document.getSpecialContracts() != null) { + List specialStrings = document.getSpecialContracts().stream() + .map(sc -> sc.getContent()) + .collect(Collectors.toList()); + dto.setSpecial(specialStrings); + } + + return dto; + } + + // 최종 계약서 작성하기 PDF -> AI + @Override + @Transactional + public MultipartFile finalContractPDF(Long contractChatId, Long userId) { + // userId 인증 + validateUserId(contractChatId, userId); + + ContractMongoDocument document = repository.getContract(contractChatId); + + int depositPrice = document.getDepositPrice(); + int monthlyRent = document.getMonthlyRent(); + int maintenanceFee = document.getMaintenanceFee(); + + int finalContract = contractMapper.insertFinalContractInit(contractChatId, depositPrice, monthlyRent, maintenanceFee); + if (finalContract != 1) throw new BusinessException(ContractException.CONTRACT_DB_INSERT); + + // DB에서 값을 가져온다 + DBFinalContractDTO dbDTO = contractMapper.selectFinalContractPDF(contractChatId); + + // 복호화 하기 + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + String ownerSsnFront = dbDTO.getOwnerSsnFront(); + String ownerSsnBack = aesCryptoUtil.decrypt(dbDTO.getOwnerSsnBack()); + String buyerSsnFront = dbDTO.getBuyerSsnFront(); + String buyerSsnBack = aesCryptoUtil.decrypt(dbDTO.getBuyerSsnBack()); + + // 추가 작업 해야할거 하기 + boolean leaseType; + + if (RentType.JEONSE.name().equals(dbDTO.getLeaseType())) { + leaseType = true; + } else if (RentType.WOLSE.name().equals(dbDTO.getLeaseType())) { + leaseType = false; // WOLSE일 때 명시적으로 false + } else { + leaseType = false; // 기타 타입도 false + } + + String buildingStructure = "철근 콘크리트 구조"; + String ownerSsn = ownerSsnFront + "-" + ownerSsnBack; + String buyerSsn = buyerSsnFront + "-" + buyerSsnBack; + + String textDepositPrice = numberFormatUtil.toKoreanNumber(document.getDepositPrice()); + String textMaintenanceFee = numberFormatUtil.toKoreanNumber(document.getMaintenanceFee()); + + LocalDate expectedMoveOut = + dbDTO.getExpectedMoveInDate() + .plusYears(dbDTO.getContractDuration().getYears()); + + // DTO 만들기 + SaveFinalContractDTO finalDTO = SaveFinalContractDTO.toDTO(dbDTO, leaseType, buildingStructure, textDepositPrice, textMaintenanceFee, expectedMoveOut, ownerSsn, buyerSsn, document, ownerVO, buyerVO); + + MultipartFile result; + // AI로 보내서 받기 + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/이건 다시 받기!"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(finalDTO, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, byte[].class); + + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + // 응답 바이트를 파일로 저장 + byte[] fileBytes = response.getBody(); + File tempFile = File.createTempFile("contract_", ".pdf"); + Files.write(tempFile.toPath(), fileBytes); + result = MultipartFileUtils.fromFile(tempFile, "contract.pdf", "application/pdf"); + + // s3에 파일 업로드 하기 + String key = s3Service.uploadFile(result); + int update = contractMapper.insertContract(contractChatId, key); + if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); + + // 받은 PDF를 반환하기 + return result; + } 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); + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + + } + + + @Override + @Transactional + public Boolean saveSignature(Long contractChatId, Long userId, SaveSignatureDTO signatureDTO, List imgFiles) throws Exception { + // userId 인증 + validateUserId(contractChatId, userId); + + if (signatureDTO == null) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "signatureDTO가 비었습니다."); + } + if (imgFiles == null || imgFiles.isEmpty()) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "서명 이미지가 비어 있습니다."); + } + + // signedType이 null인 경우 기본값 설정 + if (signatureDTO.getSignedType() == null) { + // userId로 역할 확인하여 자동 설정 + // 임시로 OWNER_CONTRACT로 설정 (실제로는 사용자 역할 확인 필요) + signatureDTO.setSignedType(SignedType.OWNER_CONTRACT); + log.warn("signedType이 null이어서 기본값으로 설정: {}", signatureDTO.getSignedType()); + } + + log.info("[saveSignature] start ccId={}, userId={}, signedType={}, 파일 개수={}", + contractChatId, userId, signatureDTO.getSignedType(), imgFiles.size()); + + // 첫 번째 서명 이미지 처리 (메인 서명) + MultipartFile mainSignature = imgFiles.get(0); + log.info("메인 서명 처리: fileName={}, size={}", + mainSignature.getOriginalFilename(), mainSignature.getSize()); + + // 사진 암호화 & 해시값 생성 + FileWithHashDto imgDTO = encryptionService.encryptImage(mainSignature); + +// MultipartFile imgFile = MultipartFileUtils.fromFile(imgDTO.getFile()); + MultipartFile imgFile; + try { + imgFile = MultipartFileUtils.fromFile(imgDTO.getFile()); + if (imgFile == null || imgFile.isEmpty()) { + throw new IllegalStateException("변환된 파일이 비어 있습니다."); + } + } catch (Exception e) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "파일 변환 실패", e); + } + + // s3에 저장하기 + String s3Key = s3Service.uploadFile(imgFile); + + log.info("[updateSignature] ccId={}, type={}, s3Key={}, hash={}", + contractChatId, signatureDTO.getSignedType(), s3Key, imgDTO.getOriginalHash()); + int save = contractMapper.insertSignature(contractChatId, s3Key, imgDTO.getOriginalHash(), signatureDTO.getSignedType(), userId); + log.info("[updateSignature] affectedRows={}", save); + + // electronic_signature에 저장하기 +// int save = contractMapper.updateSignature(contractChatId, s3Key, imgDTO.getOriginalHash(), signatureDTO.getSignedType()); + if (save != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE, "업데이트가 안 됐어요"); + + List signatures = contractMapper.selectSignature(contractChatId, userId); + log.info("============서명============="); + log.info(signatureDTO.getSignedType()); + // 상대 서명이 이미 있는지 확인 + if (signatureDTO.getSignedType() == SignedType.OWNER_CONTRACT) { + for (ElectronicSignature sig : signatures) { + if (sig.getSignedType() == SignedType.BUYER_CONTRACT) { + return true; // 임차인 서명 있음 + } + } + return false; // 임차인 서명 없음 + } else if (signatureDTO.getSignedType() == SignedType.BUYER_CONTRACT) { + for (ElectronicSignature sig : signatures) { + if (sig.getSignedType() == SignedType.OWNER_CONTRACT) { + return true; // 임대인 서명 있음 + } + } + return false; // 임대인 서명 없음 + } + if (signatureDTO.getSignedType() == SignedType.TAX || signatureDTO.getSignedType() == SignedType.PRIORITY) + return false; + throw new BusinessException(ContractException.CONTRACT_GET); // 예외처리 다시 하기! + } + + @Override + @Transactional + public byte[] saveFinalContract(Long contractChatId, Long userId, ContractPasswordDTO dto) { + // userId 인증 + validateUserId(contractChatId, userId); + + byte[] resultBytes = null; // 최종 PDF 바이트 (없으면 null 반환) + + // 동의 여부 확인 + if (!dto.getMediationAgree()) throw new BusinessException(ContractException.CONTRACT_AGREEMENT); + + // 1. 최종 사인이 있는지 여부를 확인한다. + List signatures = contractMapper.selectSignature(contractChatId, userId); + + // DB에서 값을 가져온다 + DBFinalContractDTO dbDTO = contractMapper.selectFinalContractPDF(contractChatId); + + // 몽고 DB에서 값을 가져오기 + ContractMongoDocument document = repository.getContract(contractChatId); + + // 복호화 하기 + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + String ownerSsnFront = dbDTO.getOwnerSsnFront(); + String ownerSsnBack = aesCryptoUtil.decrypt(dbDTO.getOwnerSsnBack()); + String buyerSsnFront = dbDTO.getBuyerSsnFront(); + String buyerSsnBack = aesCryptoUtil.decrypt(dbDTO.getBuyerSsnBack()); + + // 추가 작업 해야할거 하기 + boolean leaseType; + + if (RentType.JEONSE.name().equals(dbDTO.getLeaseType())) { + leaseType = true; + } else if (RentType.WOLSE.name().equals(dbDTO.getLeaseType())) { + leaseType = false; // WOLSE일 때 명시적으로 false + } else { + leaseType = false; // 기타 타입도 false + } + + String buildingStructure = "철근 콘크리트 구조"; + String ownerSsn = ownerSsnFront + "-" + ownerSsnBack; + String buyerSsn = buyerSsnFront + "-" + buyerSsnBack; + + String textDepositPrice = numberFormatUtil.toKoreanNumber(document.getDepositPrice()); + String textMaintenanceFee = numberFormatUtil.toKoreanNumber(document.getMaintenanceFee()); + + LocalDate expectedMoveOut = + dbDTO.getExpectedMoveInDate() + .plusYears(dbDTO.getContractDuration().getYears()); + + // -------- + FinalContractDTO.FinalContractDTOBuilder builder = FinalContractDTO.builder(); + builder.leaseType(leaseType); + builder.ownerNickname(document.getOwnerName()); + builder.buyerNickname(document.getBuyerName()); + builder.addr1(document.getHomeAddr1()); + builder.landCategory(dbDTO.getLandCategory()); + builder.area(dbDTO.getArea()); + builder.buildingStructure(buildingStructure); +// builder.purpose(dbDTO.getPurpose()); +// builder.totalFloorArea(dbDTO.getTotalFloorArea()); + builder.addr2(document.getHomeAddr2()); + builder.supplyArea(document.getExclusiveArea()); + builder.hasTaxArrears(dbDTO.isHasTaxArrears()); + builder.hasPriorFixedDate(dbDTO.isHasPriorFixedDate()); + builder.textDepositPrice(textDepositPrice); + builder.depositPrice(document.getDepositPrice()); + builder.monthlyRent(document.getMonthlyRent()); + builder.paymentDueDay(dbDTO.getPaymentDueDay()); + builder.bankAccount(dbDTO.getBankAccount()); + builder.textMaintenanceFee(textMaintenanceFee); + builder.maintenanceFee(document.getMaintenanceFee()); + builder.expectedMoveInYear(dbDTO.getExpectedMoveInDate().getYear()); + builder.expectedMoveInMonth(dbDTO.getExpectedMoveInDate().getMonthValue()); + builder.expectedMoveInDay(dbDTO.getExpectedMoveInDate().getDayOfMonth()); + builder.expectedMoveOutYear(expectedMoveOut.getYear()); + builder.expectedMoveOutMonth(expectedMoveOut.getMonthValue()); + builder.expectedMoveOutDay(expectedMoveOut.getDayOfMonth()); + builder.contractDateYear(dbDTO.getContractDate().getYear()); + builder.contractDateMonth(dbDTO.getContractDate().getMonthValue()); + builder.contractDateDay(dbDTO.getContractDate().getDayOfMonth()); + builder.ownerAddr(ownerVO.getAddr1() + " " + ownerVO.getAddr2()); + builder.ownerSsn(ownerSsn); + builder.ownerPhoneNumber(ownerVO.getPhoneNumber()); + builder.buyerAddr(buyerVO.getAddr1() + " " + buyerVO.getAddr2()); + builder.buyerSsn(buyerSsn); + builder.buyerPhoneNumber(buyerVO.getPhoneNumber()); + FinalContractDTO basePayLoad = builder.build(); + +// FinalContractDTO finalDTO = FinalContractDTO.toDTO(dbDTO, leaseType, buildingStructure, textDepositPrice, textMaintenanceFee, expectedMoveOut, ownerSsn, buyerSsn, document, ownerVO, buyerVO); + +// for (ElectronicSignature sign : signatures) { +// try (InputStream s3File = s3Service.downloadFile(sign.getSignatureFileKey())) { +// File contractFile = MultipartFileUtils.inputStreamToTempFile(s3File); +// +// switch (sign.getSignedType()) { +// case TAX: +// builder.ownerTaxSignature(contractFile); +// break; +// case PRIORITY: +// builder.ownerPrioritySignature(contractFile); +// break; +// case OWNER_CONTRACT: +// builder.ownerContractSignature(contractFile); +// break; +// case BUYER_CONTRACT: +// builder.buyerContractSignature(contractFile); +// break; +// default: +// throw new IllegalArgumentException("지원하지 않는 서명 타입입니다: " + sign.getSignedType()); +// } +// } catch (IOException e) { +// throw new BusinessException(ContractException.CONTRACT_INSERT, e); +// } +// } + + // ------ + + for (ElectronicSignature sign : signatures) { + + try (InputStream s3File = s3Service.downloadFile(sign.getSignatureFileKey())) { + File contractFile = MultipartFileUtils.inputStreamToTempFile(s3File); + + switch (sign.getSignedType()) { + case TAX: + builder.ownerTaxSignature(contractFile); + break; + case PRIORITY: + builder.ownerPrioritySignature(contractFile); + break; + case OWNER_CONTRACT: + builder.ownerContractSignature(contractFile); + break; + case BUYER_CONTRACT: + builder.buyerContractSignature(contractFile); + break; + default: + throw new IllegalArgumentException("지원하지 않는 서명 타입입니다: " + sign.getSignedType()); + } + } catch (IOException e) { + throw new BusinessException(ContractException.CONTRACT_INSERT, e); + } + + String redisKey = "contract:sign:" + contractChatId; + + if (sign.getSignedType() != null && sign.getSignedType() == SignedType.OWNER_CONTRACT) { + + try { + String existing = stringRedisTemplate.opsForValue().get(redisKey); + if (existing != null) { + +// // 3. DTO를 JSON 문자열로 변환 +// ObjectMapper objectMapper = new ObjectMapper(); +// String json = objectMapper.writeValueAsString(dto); +// +// // 4. Redis에 저장 +// stringRedisTemplate.opsForValue().set(redisKey, json); + + FinalContractDTO finalDTO = basePayLoad.toBuilder() + .ownerMediationAgree(dto.getMediationAgree()) + .build(); + + File tempFile; + // AI에 사인 & 동의 여부를 넘기기 -> 여기서 pdf를 같이 넘겨야 하는지 or 다시 처음부터 모든 값을 넘겨야 하는지 물어보기 + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/generate"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(finalDTO, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, byte[].class); + + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + // 응답 바이트를 파일로 저장 + byte[] fileBytes = response.getBody(); + tempFile = File.createTempFile("contract_", ".pdf"); + Files.write(tempFile.toPath(), fileBytes); + + } else { + // Sanitize response body before logging to prevent log injection + String responseBodyStr; + try { + 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); + } + + // AI 쪽에서 최종 값을 받기 (pdf) + MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); + byte[] finalContract = MultipartFileUtils.fileToBytes(tempFile); + + resultBytes = finalContract; + + try { + // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) + // Convert MultipartFile to String (base64 or file path) + String contractData = Base64.getEncoder().encodeToString(contracts.getBytes()); + encryptionService.uploadPdfStep1(contractData, dto.getContractPassword()); + // 임대인과 임차인의 생년월일 가져오기 + Long ownerId = contractMapper.getOwnerId(contractChatId); + Long buyerId = contractMapper.getBuyerId(contractChatId); + + String ownerBirthDate = contractMapper.selectBirth(ownerId).replace("-", ""); + String buyerBirthDate = contractMapper.selectBirth(buyerId).replace("-", ""); + + // 생년월일을 YYMMDD 형식으로 변환 + String ownerKey = ownerBirthDate.substring(2); // YYYY-MM-DD -> YYMMDD + String buyerKey = buyerBirthDate.substring(2); // YYYY-MM-DD -> YYMMDD + + // 두 생년월일을 조합한 암호화 키 생성 (예: owner_buyer) + String combinedKey = ownerKey + "_" + buyerKey; + + // PDF 암호화 및 S3 업로드 + FileWithHashDto encryptedPdf = encryptionService.addPasswordToPdf(contracts, combinedKey); + String s3Key = s3Service.uploadFile(MultipartFileUtils.fromFile(encryptedPdf.getFile())); + + // final_contract 테이블에 저장 + int update = contractMapper.updateFinalContract(contractChatId, s3Key, encryptedPdf.getOriginalHash()); + if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); + + log.info("최종 계약서 저장 완료 - contractChatId: {}, S3 Key: {}", contractChatId, s3Key); + } catch (Exception ex) { + // 업로드 과정의 예외를 비즈니스 예외로 변환 + throw new BusinessException(ContractException.CONTRACT_INSERT, ex); + } + + // 알림 보내기 ------------------------------------------------ + + stringRedisTemplate.delete(redisKey); + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } else if (existing == null) { + + // 3. DTO를 JSON 문자열로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(dto); + + // 4. Redis에 저장 + stringRedisTemplate.opsForValue().set(redisKey, json); + +// try { +// FileWithHashDto uploadStep2 = encryptionService.encryptPdfStep2(String.valueOf(contractChatId), dto.getContractPassword()); +// +// // S3에 저장하기 +// MultipartFile multipartContract = MultipartFileUtils.fromFile(uploadStep2.getFile()); +// String s3Keys = s3Service.uploadFile(multipartContract); +// +// // final_contract에 값을 저장하기 +// int update = contractMapper.updateFinalContract(contractChatId, s3Keys, uploadStep2.getOriginalHash()); +// if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); +// +// } catch (Exception ex) { +// // 업로드 과정의 예외를 비즈니스 예외로 변환 +// throw new BusinessException(ContractException.CONTRACT_INSERT, ex); +// } + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + + } else if (sign.getSignedType() != null && sign.getSignedType() == SignedType.BUYER_CONTRACT) { + try { + String existing = stringRedisTemplate.opsForValue().get(redisKey); + if (existing != null) { + + FinalContractDTO finalDTO = basePayLoad.toBuilder() + .ownerMediationAgree(dto.getMediationAgree()) + .build(); + + + File tempFile; + // AI에 사인 & 동의 여부를 넘기기 -> 여기서 pdf를 같이 넘겨야 하는지 or 다시 처음부터 모든 값을 넘겨야 하는지 물어보기 + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/generate"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(finalDTO, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, byte[].class); + + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + // 응답 바이트를 파일로 저장 + byte[] fileBytes = response.getBody(); + tempFile = File.createTempFile("contract_", ".pdf"); + Files.write(tempFile.toPath(), fileBytes); + + } else { + // Sanitize response body before logging to prevent log injection + String responseBodyStr; + try { + 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); + } + + // AI 쪽에서 최종 값을 받기 (pdf) + MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); + byte[] finalContract = MultipartFileUtils.fileToBytes(tempFile); + + resultBytes = finalContract; + + try { + // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) + // Convert MultipartFile to String (base64 or file path) + String contractData = Base64.getEncoder().encodeToString(contracts.getBytes()); + encryptionService.uploadPdfStep1(contractData, dto.getContractPassword()); + // 임대인과 임차인의 생년월일 가져오기 + Long ownerId = contractMapper.getOwnerId(contractChatId); + Long buyerId = contractMapper.getBuyerId(contractChatId); + + String ownerBirthDate = contractMapper.selectBirth(ownerId).replace("-", ""); + String buyerBirthDate = contractMapper.selectBirth(buyerId).replace("-", ""); + + // 생년월일을 YYMMDD 형식으로 변환 + String ownerKey = ownerBirthDate.substring(2); // YYYY-MM-DD -> YYMMDD + String buyerKey = buyerBirthDate.substring(2); // YYYY-MM-DD -> YYMMDD + + // 두 생년월일을 조합한 암호화 키 생성 (예: owner_buyer) + String combinedKey = ownerKey + "_" + buyerKey; + + // PDF 암호화 및 S3 업로드 + FileWithHashDto encryptedPdf = encryptionService.addPasswordToPdf(contracts, combinedKey); + String s3Key = s3Service.uploadFile(MultipartFileUtils.fromFile(encryptedPdf.getFile())); + + // final_contract 테이블에 저장 + int update = contractMapper.updateFinalContract(contractChatId, s3Key, encryptedPdf.getOriginalHash()); + if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); + + log.info("최종 계약서 저장 완료 - contractChatId: {}, S3 Key: {}", contractChatId, s3Key); + } catch (Exception ex) { + // 업로드 과정의 예외를 비즈니스 예외로 변환 + throw new BusinessException(ContractException.CONTRACT_INSERT, ex); + } + + // 알림 보내기 -------------------------------------- + + stringRedisTemplate.delete(redisKey); + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } else if (existing == null) { + +// try { +// FileWithHashDto uploadStep2 = encryptionService.encryptPdfStep2(String.valueOf(contractChatId), dto.getContractPassword()); +// +// // S3에 저장하기 +// MultipartFile multipartContract = MultipartFileUtils.fromFile(uploadStep2.getFile()); +// String s3Keys = s3Service.uploadFile(multipartContract); +// +// // final_contract에 값을 저장하기 +// int update = contractMapper.updateFinalContract(contractChatId, s3Keys, uploadStep2.getOriginalHash()); +// if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); +// +// stringRedisTemplate.delete(redisKey); +// } catch (Exception ex) { +// // 업로드 과정의 예외를 비즈니스 예외로 변환 +// throw new BusinessException(ContractException.CONTRACT_INSERT, ex); +// } + + // 3. DTO를 JSON 문자열로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(dto); + + // 4. Redis에 저장 + stringRedisTemplate.opsForValue().set(redisKey, json); + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } + } + + return resultBytes; + } + + @Override + public byte[] selectContractPDF(Long contractChatId, Long userId, ContractPasswordDTO dto) { + + // userId 인증 + validateUserId(contractChatId, userId); + + // s3에서 pdf를 가져온다. + // 1. 최종 사인이 있는지 여부를 확인한다. + List signatures = contractMapper.selectSignature(contractChatId, userId); + + // DB에서 값을 가져온다 + DBFinalContractDTO dbDTO = contractMapper.selectFinalContractPDF(contractChatId); + + // 몽고 DB에서 값을 가져오기 + ContractMongoDocument document = repository.getContract(contractChatId); + + // 복호화 하기 + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + String ownerSsnFront = dbDTO.getOwnerSsnFront(); + String ownerSsnBack = aesCryptoUtil.decrypt(dbDTO.getOwnerSsnBack()); + String buyerSsnFront = dbDTO.getBuyerSsnFront(); + String buyerSsnBack = aesCryptoUtil.decrypt(dbDTO.getBuyerSsnBack()); + + // 추가 작업 해야할거 하기 + boolean leaseType; + + if (RentType.JEONSE.name().equals(dbDTO.getLeaseType())) { + leaseType = true; + } else if (RentType.WOLSE.name().equals(dbDTO.getLeaseType())) { + leaseType = false; // WOLSE일 때 명시적으로 false + } else { + leaseType = false; // 기타 타입도 false + } + + String buildingStructure = "철근 콘크리트 구조"; + String ownerSsn = ownerSsnFront + "-" + ownerSsnBack; + String buyerSsn = buyerSsnFront + "-" + buyerSsnBack; + + String textDepositPrice = numberFormatUtil.toKoreanNumber(document.getDepositPrice()); + String textMaintenanceFee = numberFormatUtil.toKoreanNumber(document.getMaintenanceFee()); + + LocalDate expectedMoveOut = + dbDTO.getExpectedMoveInDate() + .plusYears(dbDTO.getContractDuration().getYears()); + + // -------- + FinalContractDTO.FinalContractDTOBuilder builder = FinalContractDTO.builder(); + builder.leaseType(leaseType); + builder.ownerNickname(document.getOwnerName()); + builder.buyerNickname(document.getBuyerName()); + builder.addr1(document.getHomeAddr1()); + builder.landCategory(dbDTO.getLandCategory()); + builder.area(dbDTO.getArea()); + builder.buildingStructure(buildingStructure); +// builder.purpose(dbDTO.getPurpose()); +// builder.totalFloorArea(dbDTO.getTotalFloorArea()); + builder.addr2(document.getHomeAddr2()); + builder.supplyArea(document.getExclusiveArea()); + builder.hasTaxArrears(dbDTO.isHasTaxArrears()); + builder.hasPriorFixedDate(dbDTO.isHasPriorFixedDate()); + builder.textDepositPrice(textDepositPrice); + builder.depositPrice(document.getDepositPrice()); + builder.monthlyRent(document.getMonthlyRent()); + builder.paymentDueDay(dbDTO.getPaymentDueDay()); + builder.bankAccount(dbDTO.getBankAccount()); + builder.textMaintenanceFee(textMaintenanceFee); + builder.maintenanceFee(document.getMaintenanceFee()); + builder.expectedMoveInYear(dbDTO.getExpectedMoveInDate().getYear()); + builder.expectedMoveInMonth(dbDTO.getExpectedMoveInDate().getMonthValue()); + builder.expectedMoveInDay(dbDTO.getExpectedMoveInDate().getDayOfMonth()); + builder.expectedMoveOutYear(expectedMoveOut.getYear()); + builder.expectedMoveOutMonth(expectedMoveOut.getMonthValue()); + builder.expectedMoveOutDay(expectedMoveOut.getDayOfMonth()); + builder.contractDateYear(dbDTO.getContractDate().getYear()); + builder.contractDateMonth(dbDTO.getContractDate().getMonthValue()); + builder.contractDateDay(dbDTO.getContractDate().getDayOfMonth()); + builder.ownerAddr(ownerVO.getAddr1() + " " + ownerVO.getAddr2()); + builder.ownerSsn(ownerSsn); + builder.ownerPhoneNumber(ownerVO.getPhoneNumber()); + builder.buyerAddr(buyerVO.getAddr1() + " " + buyerVO.getAddr2()); + builder.buyerSsn(buyerSsn); + builder.buyerPhoneNumber(buyerVO.getPhoneNumber()); + FinalContractDTO basePayLoad = builder.build(); + + for (ElectronicSignature sign : signatures) { + + try (InputStream s3File = s3Service.downloadFile(sign.getSignatureFileKey())) { + File contractFile = MultipartFileUtils.inputStreamToTempFile(s3File); + + switch (sign.getSignedType()) { + case TAX: + builder.ownerTaxSignature(contractFile); + break; + case PRIORITY: + builder.ownerPrioritySignature(contractFile); + break; + case OWNER_CONTRACT: + builder.ownerContractSignature(contractFile); + break; + case BUYER_CONTRACT: + builder.buyerContractSignature(contractFile); + break; + default: + throw new IllegalArgumentException("지원하지 않는 서명 타입입니다: " + sign.getSignedType()); + } + } catch (IOException e) { + throw new BusinessException(ContractException.CONTRACT_INSERT, e); + } + + FinalContractDTO finalDTO = basePayLoad.toBuilder() + .ownerMediationAgree(true) + .build(); + + File tempFile; + // AI에 사인 & 동의 여부를 넘기기 -> 여기서 pdf를 같이 넘겨야 하는지 or 다시 처음부터 모든 값을 넘겨야 하는지 물어보기 + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/generate"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(finalDTO, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, byte[].class); + + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + // 응답 바이트를 파일로 저장 + byte[] fileBytes = response.getBody(); + tempFile = File.createTempFile("contract_", ".pdf"); + Files.write(tempFile.toPath(), fileBytes); + + } else { + // Sanitize response body before logging to prevent log injection + String responseBodyStr; + try { + 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); + } + // AI 쪽에서 최종 값을 받기 (pdf) + MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); + + // 이거 step2로 바꿔야 함 + try { + // 임대인과 임차인의 생년월일 가져오기 (주민번호 앞자리 사용) + Long ownerId = contractMapper.getOwnerId(contractChatId); + Long buyerId = contractMapper.getBuyerId(contractChatId); + + // 주민번호 앞자리(YYMMDD)를 직접 사용 + String ownerKey = contractMapper.selectSsnFront(ownerId, contractChatId); + String buyerKey = contractMapper.selectSsnFront(buyerId, contractChatId); + + if (ownerKey == null || buyerKey == null) { + log.error("SSN front not found - owner: {}, buyer: {}", ownerKey, buyerKey); + throw new BusinessException(ContractException.CONTRACT_INSERT); + } + + // 두 생년월일을 조합한 암호화 키 생성 + String combinedKey = ownerKey + "_" + buyerKey; + + // 기존 PDF 파일이 있는지 확인하고 암호화 + // 여기서는 Redis에서 가져온 계약서를 사용 + // PDF 암호화 및 S3 업로드는 위에서 이미 처리됨 + log.info("계약서 암호화 키 생성 완료 - contractChatId: {}", contractChatId); + // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) + // Convert MultipartFile to String (base64 or file path) + String contractData = Base64.getEncoder().encodeToString(contracts.getBytes()); + encryptionService.uploadPdfStep1(contractData, dto.getContractPassword()); + + // S3에 저장하기 +// MultipartFile multipartContract = MultipartFileUtils.fromFile(uploadStep2.getFile()); +// String s3Keys = s3Service.uploadFile(multipartContract); + + // final_contract에 값을 저장하기, DB 확인 +// int update = contractMapper.updateFinalContract(contractChatId, s3Keys, uploadStep2.getOriginalHash()); +// if (update != 1) throw new BusinessException(ContractException.CONTRACT_DB_UPDATE); + } catch (Exception ex) { + // 업로드 과정의 예외를 비즈니스 예외로 변환 + throw new BusinessException(ContractException.CONTRACT_INSERT, ex); + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } + + return null; + } + + @Override + @Transactional + public Void selectContractPDF(Long contractChatId, Long userId, HttpServletResponse response, FindContractDTO + dto) throws Exception { + // userId 인증 + validateUserId(contractChatId, userId); + + // 최종 계약서 PDF를 S3에서 가져온다 + FinalContract key = contractMapper.selectFinalContract(contractChatId); + + // 최종 계약서가 존재하는지 확인 + if (key == null || key.getContractPdfKey() == null) { + log.error("최종 계약서가 존재하지 않습니다. contractChatId: {}", contractChatId); + throw new IllegalStateException("최종 계약서가 존재하지 않습니다. 먼저 계약서를 완료해주세요."); + } + + InputStream s3Contract = s3Service.downloadFile(key.getContractPdfKey()); + + MultipartFile files = MultipartFileUtils.inputStreamToMultipartFile(s3Contract); + + // 임대인과 임차인의 생년월일을 조합한 키로 복호화 (주민번호 앞자리 사용) + Long ownerId = contractMapper.getOwnerId(contractChatId); + Long buyerId = contractMapper.getBuyerId(contractChatId); + + // 주민번호 앞자리(YYMMDD)를 직접 사용 + String ownerKey = contractMapper.selectSsnFront(ownerId, contractChatId); + String buyerKey = contractMapper.selectSsnFront(buyerId, contractChatId); + + if (ownerKey == null || buyerKey == null) { + log.error("SSN front not found for decryption - owner: {}, buyer: {}", ownerKey, buyerKey); + throw new IllegalStateException("복호화를 위한 키 생성 실패: 주민번호 정보를 찾을 수 없습니다"); + } + + // 두 생년월일을 조합한 복호화 키 + String combinedKey = ownerKey + "_" + buyerKey; + + // PDF 복호화 하기 (조합된 키로 복호화) + File finalContract = encryptionService.decryptPdf(files, combinedKey, key.getContractPdfHash()); + + // 실제 파일명 -> 내가 원하는 파일명 넣어서 보내기 + String originalName = "contract_" + contractChatId + ".pdf"; + + // // 유틸로 응답 보내기 + UploadFiles.download(response, finalContract, originalName); + + return null; + } + + @Override + @Transactional + public Void sendContractPDF(Long contractChatId, Long userId, FindContractDTO dto) throws Exception { + // userId 인증 + validateUserId(contractChatId, userId); + + // 최종 계약서 PDF를 S3에서 가져온다 + FinalContract key = contractMapper.selectFinalContract(contractChatId); + + // 최종 계약서가 존재하는지 확인 + if (key == null || key.getContractPdfKey() == null) { + log.error("최종 계약서가 존재하지 않습니다. contractChatId: {}", contractChatId); + throw new IllegalStateException("최종 계약서가 존재하지 않습니다. 먼저 계약서를 완료해주세요."); + } + + InputStream s3Contract = s3Service.downloadFile(key.getContractPdfKey()); + + MultipartFile files = MultipartFileUtils.inputStreamToMultipartFile(s3Contract); + + // 임대인과 임차인의 생년월일을 조합한 키로 복호화 (주민번호 앞자리 사용) + Long ownerId = contractMapper.getOwnerId(contractChatId); + Long buyerId = contractMapper.getBuyerId(contractChatId); + + // 주민번호 앞자리(YYMMDD)를 직접 사용 + String ownerKey = contractMapper.selectSsnFront(ownerId, contractChatId); + String buyerKey = contractMapper.selectSsnFront(buyerId, contractChatId); + + if (ownerKey == null || buyerKey == null) { + log.error("SSN front not found for decryption - owner: {}, buyer: {}", ownerKey, buyerKey); + throw new IllegalStateException("복호화를 위한 키 생성 실패: 주민번호 정보를 찾을 수 없습니다"); + } + + // 두 생년월일을 조합한 복호화 키 + String combinedKey = ownerKey + "_" + buyerKey; + + // PDF 복호화 하기 (조합된 키로 복호화) + File finalContract = encryptionService.decryptPdf(files, combinedKey, key.getContractPdfHash()); + + MultipartFile finalFile = MultipartFileUtils.fromFile(finalContract); + + // PDF에 사용자 개인의 생년월일로 비밀번호 걸어서 보내기 (주민번호 앞자리 사용) + String userPassword = contractMapper.selectSsnFront(userId, contractChatId); + if (userPassword == null) { + // fallback to birth_date if SSN not found + String userBirthDate = contractMapper.selectBirth(userId); + if (userBirthDate != null) { + userPassword = userBirthDate.replace("-", "").substring(2); // YYYY-MM-DD -> YYMMDD + } else { + log.error("No birth date or SSN found for user {}", userId); + throw new IllegalStateException("사용자 생년월일 정보를 찾을 수 없습니다"); + } + } + FileWithHashDto pdfContract = encryptionService.addPasswordToPdf(finalFile, userPassword); + + // 이메일 주소 (요청된 이메일 또는 사용자 기본 이메일) + String email = dto.getEmail() != null && !dto.getEmail().isEmpty() + ? dto.getEmail() + : contractMapper.selectMail(userId); + + String subject = "[ITZeep] 계약서 PDF를 보내드립니다."; + String text = String.format( + "요청하신 계약서를 보내드립니다.\n\n" + + "PDF 열람 비밀번호: 귀하의 생년월일 6자리(YYMMDD)\n" + + "예시: 1990년 1월 1일생 → 900101\n\n" + + "문의사항이 있으시면 언제든 연락 주시기 바랍니다." + ); + + emailService.sendEmailWithAttachment(email, subject, text, pdfContract.getFile().getAbsolutePath()); + +// String pathFile = tempFile.getAbsolutePath(); +//// 변환된 파일을 이메일에 넣어서 보내기 +// emailService.sendEmailWithAttachment(email,subject, text, pathFile); +//// +//// 파일 삭제하기 +// if (tempFile.delete()) { +// log.info("임시 파일 삭제 성공: {}", tempFile.getAbsolutePath()); +// } else { +// log.warn("임시 파일 삭제 실패: {}", tempFile.getAbsolutePath()); +// } + + return null; + } + + @Override + public String getUserBirthDate(Long contractChatId, Long userId, String userRole) { + log.info("Getting birth date for contractChatId: {}, userId: {}, role: {}", contractChatId, userId, userRole); + + // 먼저 주민번호 앞자리(생년월일)를 가져옴 + String ssnFront = contractMapper.selectSsnFront(userId, contractChatId); + + if (ssnFront != null && !ssnFront.isEmpty()) { + // 주민번호 앞자리가 있으면 그대로 사용 (이미 YYMMDD 형식) + log.info("Using SSN front for birth date: {}", ssnFront); + return ssnFront; + } + + // 주민번호가 없으면 user 테이블의 birth_date 사용 + log.info("SSN front not found, falling back to birth_date from user table"); + String birthDate = contractMapper.selectBirth(userId); + + if (birthDate == null || birthDate.isEmpty()) { + log.error("Both SSN and birth date are null or empty for userId: {}", userId); + // 기본값 반환 (임시) + return "000000"; + } + + birthDate = birthDate.replace("-", ""); + + // YYYYMMDD 형식인지 확인 + if (birthDate.length() < 8) { + log.error("Invalid birth date format: {} for userId: {}", birthDate, userId); + return "000000"; + } + + return birthDate.substring(2); // YYYY-MM-DD -> YYMMDD + } + + @Override + @Transactional + public void saveFinalContractToDatabase(Long contractChatId, String s3Url, String pdfHash) { + try { + // final_contract 테이블에 저장 또는 업데이트 (INSERT ... ON DUPLICATE KEY UPDATE) + int result = contractMapper.insertOrUpdateFinalContract(contractChatId, s3Url, pdfHash); + if (result < 1) { + log.error("Failed to save final contract to database - contractChatId: {}", contractChatId); + throw new IllegalStateException("최종 계약서 저장에 실패했습니다"); + } + log.info("Final contract saved to database - contractChatId: {}, s3Url: {}, hash: {}", + contractChatId, s3Url, pdfHash); + } catch (Exception e) { + log.error("Error saving final contract to database", e); + throw new RuntimeException("최종 계약서 데이터베이스 저장 실패", e); + } + } + + // =================================================== + + // Userid 검증 + public void validateUserId (Long contractChatId, Long userId){ if (userId == null) { throw new BusinessException(PreContractErrorCode.TENANT_USER); @@ -667,4 +2089,205 @@ private static String formatWonShort(int amount) { return sb.toString(); } + @Override + public byte[] generateContractWithSignatures( + Long contractChatId, + Long userId, + java.util.List ownerSignatures, + java.util.List buyerSignatures, + boolean ownerHasTaxArrears, + boolean ownerHasPriorFixedDate, + boolean ownerMediationAgree, + boolean buyerMediationAgree) { + + log.info("Generating contract with signatures for contractChatId: {}", contractChatId); + + // userId 인증 + validateUserId(contractChatId, userId); + + // MongoDB에서 계약 정보 조회 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_NOT_FOUND); + } + + // DB에서 필요한 정보 조회 + DBFinalContractDTO dbDTO = contractMapper.selectFinalContractPDF(contractChatId); + + // 복호화 + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + // ContractChat 정보 조회하여 homeId 가져오기 + ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId); + BuildingDocumentVO buildingDocument = null; + + if (contractChat != null && contractChat.getHomeId() != null) { + // homeId로 가장 최근의 위험도 체크 조회 + RiskCheckVO latestRiskCheck = fraudRiskMapper.selectLatestRiskCheckByHomeId(contractChat.getHomeId()); + + if (latestRiskCheck != null) { + // 위험도 체크 ID로 건축물대장 정보 조회 + buildingDocument = fraudRiskMapper.selectBuildingDocumentByRiskCheckId(latestRiskCheck.getRiskckId()); + } + } + + // SaveFinalContractDTO 생성 (homeId 전달하여 주소 정보 보완) + Long homeId = contractChat != null ? contractChat.getHomeId() : null; + SaveFinalContractDTO dto = buildSaveFinalContractDTO(document, dbDTO, ownerVO, buyerVO, buildingDocument, homeId); + + // AI 서버에 PDF 생성 요청 (서명 포함) + try { + // DTO가 null이 아닌지 확인 + if (dto == null) { + log.error("SaveFinalContractDTO가 null입니다."); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED); + } + + // 체크박스 상태 설정 + dto.setHasTaxArrears(ownerHasTaxArrears); + dto.setHasPriorFixedDate(ownerHasPriorFixedDate); + + // 실제 서명 이미지 설정 (이미 Base64로 변환되어 있음 - updateSignature에서 처리) + log.info("Setting signatures from status data"); + + if (ownerSignatures != null && !ownerSignatures.isEmpty()) { + if (ownerSignatures.size() > 0 && ownerSignatures.get(0) != null) { + String ownerSign1 = ownerSignatures.get(0); + // 이미 순수 Base64 문자열이므로 그대로 설정 + dto.setOwnerSign1Base64(ownerSign1); + log.info("Owner signature 1 set - length: {}", ownerSign1.length()); + } + if (ownerSignatures.size() > 1 && ownerSignatures.get(1) != null) { + String ownerSign2 = ownerSignatures.get(1); + dto.setOwnerSign2Base64(ownerSign2); + log.info("Owner signature 2 set - length: {}", ownerSign2.length()); + } + if (ownerSignatures.size() > 2 && ownerSignatures.get(2) != null) { + String ownerSign3 = ownerSignatures.get(2); + dto.setOwnerSign3Base64(ownerSign3); + log.info("Owner signature 3 set - length: {}", ownerSign3.length()); + } + } else { + dto.setOwnerSign1Base64(""); + dto.setOwnerSign2Base64(""); + dto.setOwnerSign3Base64(""); + } + + if (buyerSignatures != null && !buyerSignatures.isEmpty() && buyerSignatures.get(0) != null) { + String buyerSign1 = buyerSignatures.get(0); + log.info("Processing buyer signature - length: {}", buyerSign1.length()); + + // 이미 순수 Base64 문자열이므로 그대로 설정 + dto.setBuyerSignBase64(buyerSign1); + log.info("Buyer signature set - length: {}", buyerSign1.length()); + if (buyerSign1.length() > 50) { + log.info("Buyer signature preview: {}", buyerSign1.substring(0, 50) + "..."); + } + } else { + log.warn("No buyer signature provided - setting empty string"); + dto.setBuyerSignBase64(""); + } + + log.info("Signatures set - Owner: {}, Buyer: {}", + ownerSignatures != null ? ownerSignatures.size() : 0, + buyerSignatures != null ? buyerSignatures.size() : 0); + + // 서명 데이터가 실제로 있는지 확인 + log.info("Final DTO check before sending to AI server:"); + log.info(" - Owner Sign1 empty: {}, length: {}", + dto.getOwnerSign1Base64().isEmpty(), + dto.getOwnerSign1Base64().length()); + log.info(" - Buyer Sign1 empty: {}, length: {}", + dto.getBuyerSignBase64().isEmpty(), + dto.getBuyerSignBase64().length()); + + // 임차인 서명이 정말 설정되었는지 최종 확인 + if (dto.getBuyerSignBase64() != null && !dto.getBuyerSignBase64().isEmpty()) { + log.info("✓ Buyer signature is SET and will be sent to AI server"); + log.info(" Buyer signature data starts with: {}", + dto.getBuyerSignBase64().substring(0, Math.min(30, dto.getBuyerSignBase64().length()))); + } else { + log.error("✗ Buyer signature is NULL or EMPTY - AI server will NOT receive buyer signature!"); + } + + // JSON으로 전송 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Accept", "application/pdf"); + + HttpEntity request = new HttpEntity<>(dto, headers); + + ResponseEntity response = restTemplate.exchange( + aiServerUrl + "/api/contract/generate-json", + HttpMethod.POST, + request, + byte[].class); + + if (response.getStatusCode() == HttpStatus.OK) { + byte[] pdfBytes = response.getBody(); + log.info("AI 서버에서 서명 포함 PDF 생성 성공. 크기: {} bytes", + pdfBytes != null ? pdfBytes.length : 0); + return pdfBytes; + } else { + log.error("AI 서버 PDF 생성 실패. HTTP 상태: {}", response.getStatusCode()); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED); + } + + } catch (Exception e) { + log.error("AI 서버 PDF 생성 중 오류 발생", e); + throw new BusinessException(ContractException.PDF_GENERATION_FAILED, e); + } + } + + @Override + public byte[] getExistingContractPdf(Long contractChatId) { + try { + log.info("Attempting to retrieve existing contract PDF for contractChatId: {}", contractChatId); + + // MongoDB에서 계약서 조회 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + log.warn("No contract found in MongoDB for contractChatId: {}", contractChatId); + return null; + } + + // 간단한 PDF 생성 (기본 계약서 템플릿) + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + com.itextpdf.kernel.pdf.PdfWriter writer = new com.itextpdf.kernel.pdf.PdfWriter(baos); + com.itextpdf.kernel.pdf.PdfDocument pdfDoc = new com.itextpdf.kernel.pdf.PdfDocument(writer); + com.itextpdf.layout.Document doc = new com.itextpdf.layout.Document(pdfDoc); + + // 계약서 기본 내용 추가 + doc.add(new com.itextpdf.layout.element.Paragraph("부동산 임대차 계약서") + .setFontSize(18) + .setBold()); + doc.add(new com.itextpdf.layout.element.Paragraph("계약 ID: " + contractChatId)); + + // MongoDB 문서에서 데이터 추출하여 추가 + if (document.getOwnerName() != null) { + doc.add(new com.itextpdf.layout.element.Paragraph("임대인: " + document.getOwnerName())); + } + if (document.getBuyerName() != null) { + doc.add(new com.itextpdf.layout.element.Paragraph("임차인: " + document.getBuyerName())); + } + if (document.getHomeAddr1() != null) { + doc.add(new com.itextpdf.layout.element.Paragraph("주소: " + document.getHomeAddr1() + " " + + (document.getHomeAddr2() != null ? document.getHomeAddr2() : ""))); + } + + doc.close(); + + byte[] pdfBytes = baos.toByteArray(); + log.info("Generated existing contract PDF with size: {} bytes", pdfBytes.length); + return pdfBytes; + + } catch (Exception e) { + log.error("Failed to retrieve or generate existing contract PDF", e); + return null; + } + } } diff --git a/src/main/java/org/scoula/domain/contract/vo/ElectronicSignature.java b/src/main/java/org/scoula/domain/contract/vo/ElectronicSignature.java new file mode 100644 index 00000000..4578ceb1 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/vo/ElectronicSignature.java @@ -0,0 +1,27 @@ +package org.scoula.domain.contract.vo; + +import java.time.LocalDateTime; + +import org.scoula.domain.contract.enums.SignedType; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ElectronicSignature { + + private Long signatureId; + private Long contractId; + private Long identityVerificationId; + + private String signatureFileKey; + private String signatureFileHash; + private SignedType signedType; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/org/scoula/domain/contract/vo/FinalContract.java b/src/main/java/org/scoula/domain/contract/vo/FinalContract.java new file mode 100644 index 00000000..a85e8f95 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/vo/FinalContract.java @@ -0,0 +1,32 @@ +package org.scoula.domain.contract.vo; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FinalContract { + + private Long contractId; + private Long homeId; + private Long ownerId; + private Long buyerId; + + private String contractPdfKey; + private String contractPdfHash; + private LocalDateTime contractDate; + private LocalDateTime contractExpireDate; + private LocalDateTime ownerIdentityVerifiedAt; + private LocalDateTime buyerIdentityVerifiedAt; + + private int depositPrice; + private int monthlyRent; + private int maintenanceFee; + private LocalDateTime createdAt; +} diff --git a/src/main/java/org/scoula/domain/contract/websocket/ContractExportWebSocketHandler.java b/src/main/java/org/scoula/domain/contract/websocket/ContractExportWebSocketHandler.java new file mode 100644 index 00000000..f6e94a30 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/websocket/ContractExportWebSocketHandler.java @@ -0,0 +1,133 @@ +package org.scoula.domain.contract.websocket; + +import org.scoula.domain.contract.dto.ContractExportStatusDTO; +import org.scoula.domain.contract.dto.PasswordSubmitDTO; +import org.scoula.domain.contract.dto.SignatureSubmitDTO; +import org.scoula.domain.contract.service.ContractExportSyncService; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +/** 계약서 내보내기 WebSocket 핸들러 임대인과 임차인 간의 실시간 동기화를 처리 */ +@Controller +@RequiredArgsConstructor +@Log4j2 +public class ContractExportWebSocketHandler { + + private final SimpMessagingTemplate messagingTemplate; + private final ContractExportSyncService syncService; + private final ObjectMapper objectMapper; + + /** 계약서 내보내기 세션 참가 */ + @MessageMapping("/contract/{contractChatId}/export/join") + @SendTo("/topic/contract/{contractChatId}/export/status") + public ContractExportStatusDTO joinExportSession( + @DestinationVariable Long contractChatId, @Payload String userId) { + + log.info("User {} joined contract export session for contract {}", userId, contractChatId); + + // 현재 상태 반환 + return syncService.getExportStatus(contractChatId); + } + + /** 서명 제출 */ + @MessageMapping("/contract/{contractChatId}/export/signature") + @SendTo("/topic/contract/{contractChatId}/export/status") + public ContractExportStatusDTO submitSignature( + @DestinationVariable Long contractChatId, @Payload SignatureSubmitDTO signatureData) { + + log.info( + "Signature submitted for contract {} by {}", + contractChatId, + signatureData.getUserRole()); + log.info("Signature data: {}", signatureData); + + // 서명 데이터 저장 및 상태 업데이트 + ContractExportStatusDTO updatedStatus = + syncService.updateSignature(contractChatId, signatureData); + + // 상태 업데이트는 이미 syncService에서 처리됨 + // (양측 서명 완료 시 자동으로 최종 PDF 생성) + log.info( + "Signature status updated for contract {}: step={}, completed={}, ownerSigned={}," + + " buyerSigned={}", + contractChatId, + updatedStatus.getCurrentStep(), + updatedStatus.isCompleted(), + updatedStatus.isOwnerSignatureCompleted(), + updatedStatus.isBuyerSignatureCompleted()); + + return updatedStatus; + } + + /** 암호 설정 */ + @MessageMapping("/contract/{contractChatId}/export/password") + @SendTo("/topic/contract/{contractChatId}/export/status") + public ContractExportStatusDTO submitPassword( + @DestinationVariable Long contractChatId, @Payload PasswordSubmitDTO passwordData) { + + log.info( + "Password submitted for contract {} by {}", + contractChatId, + passwordData.getUserRole()); + + // 암호 저장 및 상태 업데이트 + ContractExportStatusDTO updatedStatus = + syncService.updatePassword(contractChatId, passwordData); + + // 양측 암호 설정 완료 확인 + if (updatedStatus.isBothPasswordsSet()) { + log.info("Both parties have set passwords for contract {}", contractChatId); + + // 최종 PDF 생성 + try { + String finalPdfUrl = syncService.generateFinalPdf(contractChatId); + updatedStatus.setFinalPdfUrl(finalPdfUrl); + updatedStatus.setCurrentStep("complete"); + updatedStatus.setCompleted(true); + + log.info("Final PDF generated for contract {}: {}", contractChatId, finalPdfUrl); + } catch (Exception e) { + log.error("Failed to generate final PDF for contract {}", contractChatId, e); + } + } + + return updatedStatus; + } + + /** 진행 상태 조회 */ + @MessageMapping("/contract/{contractChatId}/export/status") + @SendTo("/topic/contract/{contractChatId}/export/status") + public ContractExportStatusDTO getStatus(@DestinationVariable Long contractChatId) { + + return syncService.getExportStatus(contractChatId); + } + + /** 세션 나가기 */ + @MessageMapping("/contract/{contractChatId}/export/leave") + public void leaveExportSession( + @DestinationVariable Long contractChatId, @Payload String userId) { + + log.info("User {} left contract export session for contract {}", userId, contractChatId); + } + + /** 특정 계약에 상태 업데이트 브로드캐스트 */ + public void broadcastStatusUpdate(Long contractChatId, ContractExportStatusDTO status) { + messagingTemplate.convertAndSend( + "/topic/contract/" + contractChatId + "/export/status", status); + } + + /** 에러 메시지 전송 */ + public void sendError(Long contractChatId, String errorMessage) { + messagingTemplate.convertAndSend( + "/topic/contract/" + contractChatId + "/export/error", errorMessage); + } +} diff --git a/src/main/java/org/scoula/domain/precontract/enums/ContractDuration.java b/src/main/java/org/scoula/domain/precontract/enums/ContractDuration.java index 70905c5f..4d7aaeb2 100644 --- a/src/main/java/org/scoula/domain/precontract/enums/ContractDuration.java +++ b/src/main/java/org/scoula/domain/precontract/enums/ContractDuration.java @@ -6,10 +6,11 @@ @Getter @RequiredArgsConstructor public enum ContractDuration { - YEAR_1("1년 계약"), - YEAR_2("2년 계약"), - YEAR_3("3년 계약"), - YEAR_4("4년 계약"), - YEAR_5("5년 계약"); + YEAR_1("1년 계약", 1), + YEAR_2("2년 계약", 2), + YEAR_3("3년 계약", 3), + YEAR_4("4년 계약", 4), + YEAR_5("5년 계약", 5); private final String displayName; + private final int years; } diff --git a/src/main/java/org/scoula/global/common/util/ImgAesCryptoUtil.java b/src/main/java/org/scoula/global/common/util/ImgAesCryptoUtil.java new file mode 100644 index 00000000..7a76cd1f --- /dev/null +++ b/src/main/java/org/scoula/global/common/util/ImgAesCryptoUtil.java @@ -0,0 +1,222 @@ +package org.scoula.global.common.util; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.annotation.PostConstruct; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.log4j.Log4j2; + +/** + * 이미지/바이너리 AES-GCM 암복호화 유틸. PNG/JPG 등 "포맷 무관" - 바이트 배열로 처리하므로 어떤 바이너리도 암/복호화 가능. 암호문 형식: Base64( + * IV(12바이트) + CIPHERTEXT(암호문+TAG) ) - IV는 매 호출마다 SecureRandom으로 생성 - 키는 application.properties의 + * crypto.aes.secret-key(Base64 인코딩된 32바이트)를 사용 + */ +@Component +@Log4j2 +public class ImgAesCryptoUtil { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_SIZE = 12; // 96 bits + private static final int GCM_TAG_LENGTH = 128; // bits + private static final int KEY_LEN = 32; // 256-bit + + @Value("${crypto.aes.secret-key:#{null}}") + private String secretKeyString; // Base64로 인코딩된 32바이트 키 + + private SecretKey secretKey; + private SecureRandom secureRandom; + + @PostConstruct + public void init() { + if (secretKeyString == null || secretKeyString.isEmpty()) { + throw new IllegalStateException( + "AES 키가 없습니다. crypto.aes.secret-key 를 설정하세요 (Base64-encoded 32 bytes)."); + } + try { + byte[] decodedKey = Base64.getDecoder().decode(secretKeyString); + if (decodedKey.length != KEY_LEN) { + throw new IllegalStateException( + "AES-256 키 길이 오류: " + decodedKey.length + " bytes (32 필요)"); + } + this.secretKey = new SecretKeySpec(decodedKey, ALGORITHM); + this.secureRandom = new SecureRandom(); + log.info("ImgAesCryptoUtil initialized."); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("잘못된 Base64 형식의 AES 키입니다.", e); + } + } + + // ======================== Public APIs ======================== + /** 바이트 배열 → AES-GCM → Base64(IV+암호문) */ + public String encryptBytesToBase64(byte[] plainBytes) { + if (plainBytes == null || plainBytes.length == 0) return null; + byte[] combined = encryptBytesInternal(plainBytes); + return Base64.getEncoder().encodeToString(combined); + } + + // ------------------------------ + /** Base64(IV+암호문) → 복호화 → 바이트 배열 */ + public byte[] decryptBase64ToBytes(String base64Cipher) { + if (base64Cipher == null || base64Cipher.isEmpty()) return null; + byte[] combined = Base64.getDecoder().decode(base64Cipher); + return decryptBytesInternal(combined); + } + + /** 파일(예: PNG/JPG/PDF 등 바이너리) → 암호화(Base64) */ + public String encryptFileToBase64(File file) throws IOException { + if (file == null) return null; + byte[] plain = java.nio.file.Files.readAllBytes(file.toPath()); + return encryptBytesToBase64(plain); + } + + /** Base64(IV+암호문) → 복호화 → 파일로 저장 (확장자는 원본과 일치 권장) */ + public void decryptBase64ToFile(String base64Cipher, File outFile) throws IOException { + if (base64Cipher == null || base64Cipher.isEmpty() || outFile == null) return; + byte[] plain = decryptBase64ToBytes(base64Cipher); + try (FileOutputStream fos = new FileOutputStream(outFile)) { + fos.write(plain); + } + } + + // --------------------------- + /** 업로드(MultipartFile) → 암호화(Base64). 이미지/서명 파일 등에 바로 사용 */ + public String encryptMultipartFileToBase64(MultipartFile multipartFile) throws IOException { + if (multipartFile == null || multipartFile.isEmpty()) return null; + return encryptBytesToBase64(multipartFile.getBytes()); + } + + /** InputStream → 암호화(Base64). S3/네트워크 스트림 등에서 유용 */ + public String encryptStreamToBase64(InputStream in) throws IOException { + if (in == null) return null; + byte[] plain = readAll(in); + return encryptBytesToBase64(plain); + } + + // ======================== Hash Utilities ======================== + /** 바이트 배열 → SHA-256 HEX 문자열 (무결성 검증용) */ + public static String sha256Hex(byte[] data) { + if (data == null) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 사용 불가", e); + } + } + + /** 파일 → SHA-256 HEX */ + public static String sha256Hex(File file) throws IOException { + if (file == null) return null; + byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath()); + return sha256Hex(bytes); + } + + /** 업로드 파일 → SHA-256 HEX */ + public static String sha256Hex(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) return null; + return sha256Hex(file.getBytes()); + } + + /** 바이트 배열 → SHA-256 Base64 */ + public static String sha256Base64(byte[] data) { + if (data == null) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 사용 불가", e); + } + } + + // ======================== Internal Impl ======================== + private byte[] encryptBytesInternal(byte[] plainBytes) { + try { + byte[] iv = new byte[IV_SIZE]; + secureRandom.nextBytes(iv); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); + byte[] encrypted = cipher.doFinal(plainBytes); + byte[] combined = new byte[IV_SIZE + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, IV_SIZE); + System.arraycopy(encrypted, 0, combined, IV_SIZE, encrypted.length); + return combined; + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | InvalidKeyException + | InvalidAlgorithmParameterException + | IllegalBlockSizeException + | BadPaddingException e) { + log.error("이미지/바이너리 암호화 실패", e); + throw new RuntimeException("이미지/바이너리 암호화 실패", e); + } + } + + private byte[] decryptBytesInternal(byte[] combined) { + try { + if (combined.length < IV_SIZE) throw new IllegalArgumentException("잘못된 암호화 데이터"); + byte[] iv = new byte[IV_SIZE]; + byte[] encryptedBytes = new byte[combined.length - IV_SIZE]; + System.arraycopy(combined, 0, iv, 0, IV_SIZE); + System.arraycopy(combined, IV_SIZE, encryptedBytes, 0, encryptedBytes.length); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); + return cipher.doFinal(encryptedBytes); + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | InvalidKeyException + | InvalidAlgorithmParameterException + | IllegalBlockSizeException + | BadPaddingException e) { + log.error("이미지/바이너리 복호화 실패", e); + throw new RuntimeException("이미지/바이너리 복호화 실패", e); + } + } + + private static byte[] readAll(InputStream in) throws IOException { + try (InputStream input = in; + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = input.read(buf)) != -1) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } + } + + /** 초기설정용 256-bit 키 생성기 (Base64 문자열) */ + public static String generateRandomKey() { + SecureRandom random = new SecureRandom(); + byte[] key = new byte[KEY_LEN]; + random.nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } +} diff --git a/src/main/java/org/scoula/global/common/util/MultipartFileUtils.java b/src/main/java/org/scoula/global/common/util/MultipartFileUtils.java new file mode 100644 index 00000000..78992f38 --- /dev/null +++ b/src/main/java/org/scoula/global/common/util/MultipartFileUtils.java @@ -0,0 +1,192 @@ +package org.scoula.global.common.util; + +import java.io.*; +import java.nio.file.Files; +import java.util.Objects; + +import org.springframework.web.multipart.MultipartFile; + +/** File ↔ MultipartFile 변환 유틸 (운영 코드용, spring-test 불필요) */ +// 파일 변환 유틸 +public final class MultipartFileUtils { + + private MultipartFileUtils() {} + + /** File → MultipartFile (커스텀 구현체로 감싸기) */ + public static MultipartFile fromFile(File file) { + return fromFile(file, file.getName(), probeContentType(file)); + } + + /** File → MultipartFile (파일명/컨텐츠타입 지정 가능) */ + public static MultipartFile fromFile(File file, String originalFilename, String contentType) { + Objects.requireNonNull(file, "file must not be null"); + return new FileMultipartFile(file, originalFilename, contentType); + } + + /** MultipartFile → 임시 File (호출자가 삭제 책임) */ + public static File toTempFile(MultipartFile multipart, String prefix, String suffix) + throws IOException { + Objects.requireNonNull(multipart, "multipart must not be null"); + File temp = File.createTempFile(prefix, suffix); + try (InputStream in = multipart.getInputStream()) { + Files.copy(in, temp.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return temp; + } + + /** InputStream → 임시 File (기본 prefix/suffix 사용: "contract_", ".pdf") */ + public static File inputStreamToTempFile(InputStream in) throws IOException { + return inputStreamToTempFile(in, "contract_", ".pdf"); + } + + /** InputStream → 임시 File (호출자가 삭제 책임) */ + public static File inputStreamToTempFile(InputStream in, String prefix, String suffix) + throws IOException { + Objects.requireNonNull(in, "inputStream must not be null"); + if (prefix == null || prefix.isBlank()) prefix = "contract_"; + if (suffix == null || suffix.isBlank()) suffix = ".pdf"; + + File temp = File.createTempFile(prefix, suffix); + try (InputStream src = in) { + Files.copy(src, temp.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return temp; + } + + /** InputStream → MultipartFile (기본 값 사용) */ + public static MultipartFile inputStreamToMultipartFile(InputStream in) throws IOException { + return inputStreamToMultipartFile(in, "file", "application/octet-stream"); + } + + /** InputStream → MultipartFile (파일명 및 컨텐츠타입 지정 가능) */ + public static MultipartFile inputStreamToMultipartFile( + InputStream in, String originalFilename, String contentType) throws IOException { + Objects.requireNonNull(in, "inputStream must not be null"); + if (originalFilename == null || originalFilename.isBlank()) { + originalFilename = "file"; + } + if (contentType == null || contentType.isBlank()) { + contentType = "application/octet-stream"; + } + // 임시 파일을 만든 뒤 MultipartFile로 변환 + File tempFile = inputStreamToTempFile(in); + return fromFile(tempFile, originalFilename, contentType); + } + + /** File → byte[] (전체 파일 메모리에 적재) */ + public static byte[] fileToBytes(File file) throws IOException { + Objects.requireNonNull(file, "file must not be null"); + return Files.readAllBytes(file.toPath()); + } + + /** MultipartFile → byte[] (전체 파일 메모리에 적재) */ + public static byte[] multipartToBytes(MultipartFile multipart) throws IOException { + Objects.requireNonNull(multipart, "multipart must not be null"); + return multipart.getBytes(); // 내부적으로 InputStream을 모두 읽어 byte[]로 반환 + } + + /** InputStream → byte[] (전체 스트림 메모리에 적재) */ + public static byte[] inputStreamToBytes(InputStream in) throws IOException { + Objects.requireNonNull(in, "inputStream must not be null"); + try (InputStream src = in; + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = src.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } + + /** byte[] → 임시 File (호출자가 삭제 책임) */ + public static File bytesToTempFile(byte[] bytes, String prefix, String suffix) + throws IOException { + Objects.requireNonNull(bytes, "bytes must not be null"); + if (prefix == null || prefix.isBlank()) prefix = "contract_"; + if (suffix == null || suffix.isBlank()) suffix = ".bin"; + File temp = File.createTempFile(prefix, suffix); + Files.write(temp.toPath(), bytes); + return temp; + } + + // 파일 삭제 예시 + // File tempFile = MultipartFileUtils.toTempFile(multipart, "sig_", ".png"); + // + // try { + // // tempFile 사용 (예: 암호화, 업로드 등) + // s3Service.uploadFile(tempFile); + // } finally { + // // 사용 후 직접 삭제 + // if (tempFile.delete()) { + // log.info("임시 파일 삭제 완료: {}", tempFile.getAbsolutePath()); + // } else { + // log.warn("임시 파일 삭제 실패: {}", tempFile.getAbsolutePath()); + // } + // } + + private static String probeContentType(File file) { + try { + String ct = Files.probeContentType(file.toPath()); + return (ct != null) ? ct : "application/octet-stream"; + } catch (IOException e) { + return "application/octet-stream"; + } + } + + /** 운영 코드에서 사용할 간단한 MultipartFile 구현체 (파일을 래핑) */ + private static final class FileMultipartFile implements MultipartFile { + private final File file; + private final String originalFilename; + private final String contentType; + + FileMultipartFile(File file, String originalFilename, String contentType) { + this.file = file; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return file.length() == 0; + } + + @Override + public long getSize() { + return file.length(); + } + + @Override + public byte[] getBytes() throws IOException { + return Files.readAllBytes(file.toPath()); + } + + @Override + public InputStream getInputStream() throws IOException { + return new BufferedInputStream(new FileInputStream(file)); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.copy( + file.toPath(), + dest.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/main/java/org/scoula/global/common/util/NumberFormatUtil.java b/src/main/java/org/scoula/global/common/util/NumberFormatUtil.java new file mode 100644 index 00000000..782c6095 --- /dev/null +++ b/src/main/java/org/scoula/global/common/util/NumberFormatUtil.java @@ -0,0 +1,88 @@ +package org.scoula.global.common.util; + +import org.springframework.stereotype.Component; + +import lombok.extern.log4j.Log4j2; + +// @Component, @Log4j2 는 유틸에선 보통 불필요해서 제거했습니다. +// 필요하면 남겨도 되지만, 주입받을 일 없으면 빼는 게 좋아요. +@Component +@Log4j2 +public final class NumberFormatUtil { + + // === 상수 정의 === + private static final String[] KOREAN_DIGITS = {"", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"}; + private static final String[] KOREAN_UNITS = {"", "십", "백", "천"}; + private static final String[] KOREAN_BIG_UNITS = {"", "만", "억", "조", "경"}; + + /** 1억 2천 3만 형태의 짧은 표기 */ + public 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("만"); + 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 { + sb.append(man).append("만원"); + } + } + return sb.toString().replaceAll("\\s+", " "); + } + + // ======================= + // 숫자만 한글로 (예: 19091 -> "일만구천구십일") + public String toKoreanNumber(int amount) { + if (amount == 0) return "영"; + StringBuilder result = new StringBuilder(); + int unitPos = 0; // 만/억/조/경 단위 인덱스 + while (amount > 0) { + int chunk = amount % 10000; // 4자리 묶음 + if (chunk > 0) { + String chunkText = convertChunkNatural(chunk); + // 큰 단위 붙이기 + if (!KOREAN_BIG_UNITS[unitPos].isEmpty()) { + if (chunk == 1) { + // 정확히 1묶음이면 '일만', '일억'처럼 '일' 명시 + chunkText = "일" + KOREAN_BIG_UNITS[unitPos]; + } else { + chunkText += KOREAN_BIG_UNITS[unitPos]; + } + } + result.insert(0, chunkText); + } + amount /= 10000; + unitPos++; + } + return result.toString(); + } + + /** 0~9999를 자연스러운 한글로 변환 (십/백/천 자리에 '일'은 생략) */ + private String convertChunkNatural(int n) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 4; i++) { + int digit = n % 10; + if (digit > 0) { + String digitText = KOREAN_DIGITS[digit]; + // 십/백/천 자리에서는 '일' 생략 (예: 일십 -> 십, 일백 -> 백, 일천 -> 천) + if (i > 0 && digit == 1) digitText = ""; + sb.insert(0, digitText + KOREAN_UNITS[i]); + } + n /= 10; + } + return sb.toString(); + } +} diff --git a/src/main/java/org/scoula/global/config/ServletConfig.java b/src/main/java/org/scoula/global/config/ServletConfig.java index 9905ff7d..033141da 100644 --- a/src/main/java/org/scoula/global/config/ServletConfig.java +++ b/src/main/java/org/scoula/global/config/ServletConfig.java @@ -1,19 +1,17 @@ package org.scoula.global.config; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.converter.Converter; -import org.springframework.format.FormatterRegistry; +import org.springframework.http.MediaType; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.support.StandardServletMultipartResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -21,12 +19,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; @Configuration @EnableWebMvc -@EnableAsync -@Log4j2 @ComponentScan( basePackages = { "org.scoula.domain", @@ -76,24 +71,38 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { @Override public void configureMessageConverters(List> converters) { - MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); - converter.setObjectMapper(objectMapper); - converters.add(converter); + // ByteArrayHttpMessageConverter for PDF and other binary data + ByteArrayHttpMessageConverter byteArrayConverter = new ByteArrayHttpMessageConverter(); + byteArrayConverter.setSupportedMediaTypes( + List.of( + MediaType.APPLICATION_PDF, + MediaType.APPLICATION_OCTET_STREAM, + MediaType.IMAGE_PNG, + MediaType.IMAGE_JPEG, + MediaType.ALL)); + converters.add(byteArrayConverter); + + // JSON converter + MappingJackson2HttpMessageConverter jsonConverter = + new MappingJackson2HttpMessageConverter(); + jsonConverter.setObjectMapper(objectMapper); + converters.add(jsonConverter); } @Override - public void addFormatters(FormatterRegistry registry) { - // LocalDate converter for form data - registry.addConverter( - new Converter() { - @Override - public LocalDate convert(String source) { - if (source == null || source.trim().isEmpty()) { - return null; - } - return LocalDate.parse(source, DateTimeFormatter.ISO_LOCAL_DATE); - } - }); + 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); } // @Override diff --git a/src/main/java/org/scoula/global/file/service/S3ServiceImpl.java b/src/main/java/org/scoula/global/file/service/S3ServiceImpl.java index 633b5dbf..6c6d6632 100644 --- a/src/main/java/org/scoula/global/file/service/S3ServiceImpl.java +++ b/src/main/java/org/scoula/global/file/service/S3ServiceImpl.java @@ -17,14 +17,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; +import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.GetUrlRequest; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; @@ -304,6 +307,45 @@ private String generateFileName(String originalFilename) { return UUID.randomUUID().toString() + extension; } + /** {@inheritDoc} */ + @Override + public String uploadBytes(byte[] data, String fileName, String contentType) { + if (data == null || data.length == 0) { + throw new BusinessException(S3ErrorCode.EMPTY_FILE, "빈 데이터는 업로드할 수 없습니다"); + } + + if (fileName == null || fileName.trim().isEmpty()) { + throw new BusinessException(S3ErrorCode.INVALID_FILE_NAME, "파일명이 올바르지 않습니다"); + } + + String key = "contracts/" + fileName; + + return executeSafely( + () -> { + try { + PutObjectRequest putObjectRequest = + PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType( + contentType != null + ? contentType + : "application/pdf") + .contentLength((long) data.length) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(data)); + + // S3 URL 반환 + return getFileUrl(key); + } catch (Exception e) { + throw new BusinessException( + S3ErrorCode.FILE_UPLOAD_FAILED, "S3 파일 업로드에 실패했습니다", e); + } + }, + "S3 바이트 배열 업로드"); + } + /** {@inheritDoc} */ @Override public String uploadProfileImageFromUrl(String imageUrl, Long userId) { @@ -397,4 +439,26 @@ public String uploadProfileImageFromUrl(String imageUrl, Long userId) { }, "프로필 이미지 S3 업로드"); } + + @Override + public byte[] downloadBytes(String key) { + return executeSafely( + () -> { + try { + GetObjectRequest getObjectRequest = + GetObjectRequest.builder().bucket(bucketName).key(key).build(); + + ResponseBytes objectBytes = + s3Client.getObjectAsBytes(getObjectRequest); + + log.info("S3에서 파일 다운로드 완료: {}", key); + return objectBytes.asByteArray(); + + } catch (S3Exception e) { + log.error("S3 파일 다운로드 실패: {}", key, e); + throw new BusinessException(S3ErrorCode.FILE_NOT_FOUND); + } + }, + "S3 파일 다운로드"); + } } diff --git a/src/main/java/org/scoula/global/file/service/S3ServiceInterface.java b/src/main/java/org/scoula/global/file/service/S3ServiceInterface.java index b47c69ed..8b2679e6 100644 --- a/src/main/java/org/scoula/global/file/service/S3ServiceInterface.java +++ b/src/main/java/org/scoula/global/file/service/S3ServiceInterface.java @@ -95,4 +95,23 @@ public interface S3ServiceInterface { * @return S3에 저장된 이미지 URL */ String uploadProfileImageFromUrl(String imageUrl, Long userId); + + /** + * 바이트 배열로 파일을 S3에 업로드합니다. + * + * @param data 업로드할 데이터 + * @param fileName 파일명 + * @param contentType 컨텐츠 타입 + * @return 업로드된 파일의 S3 URL + */ + String uploadBytes(byte[] data, String fileName, String contentType); + + /** + * S3에서 파일을 바이트 배열로 다운로드합니다. + * + * @param key S3 파일 키 + * @return 파일의 바이트 배열 + * @throws BusinessException 파일 다운로드 실패 시 + */ + byte[] downloadBytes(String key); } diff --git a/src/main/resources/org/scoula/domain/contract/mapper/ContractMapper.xml b/src/main/resources/org/scoula/domain/contract/mapper/ContractMapper.xml index 10aa8332..794a617c 100644 --- a/src/main/resources/org/scoula/domain/contract/mapper/ContractMapper.xml +++ b/src/main/resources/org/scoula/domain/contract/mapper/ContractMapper.xml @@ -61,65 +61,179 @@ WHERE cc.contract_chat_id = #{contractChatId} - - INSERT INTO electronic_signature (contract_id, identity_verification_id, owner_id, buyer_id, created_at) - SELECT fc.contract_id, iv.identity_id, cc.owner_id, cc.buyer_id, NOW() + + + insert into final_contract (contract_id, home_id, owner_id, buyer_id, owner_identity_verified_at, buyer_identity_verified_at, deposit_price, monthly_rent, maintenance_fee, created_at) + select #{contractChatId}, cc.home_id, cc.owner_id, cc.buyer_id, oiv.identity_verified_at, biv.identity_verified_at, #{depositPrice}, #{monthlyRent}, #{maintenanceFee}, NOW() FROM contract_chat cc - INNER JOIN final_contract fc - ON cc.home_id = fc.home_id AND cc.owner_id = fc.owner_id - LEFT JOIN identity_verification iv - ON iv.contract_id = cc.contract_chat_id - WHERE cc.contract_chat_id = 4; + LEFT JOIN identity_verification oiv + ON oiv.user_id = cc.owner_id + AND oiv.contract_id = cc.contract_chat_id + LEFT JOIN identity_verification biv + ON biv.user_id = cc.buyer_id + AND biv.contract_id = cc.contract_chat_id + WHERE cc.contract_chat_id = #{contractChatId} - - UPDATE electronic_signature - SET owner_tax_signature_file_url = #{url}, owner_tax_file_hash = #{hashKey}, owner_tax_signed_at = NOW() - WHERE contract_id = #{finalContractId} + + + + update final_contract + set contract_pdf_key = #{s3Key} + WHERE contract_id = #{contractChatId} - - INSERT INTO final_contract ( + + + + + + + + + + + + + + + + + + + + + + + + + + + insert into electronic_signature (contract_id, identity_verification_id, signature_file_key, signature_file_hash, signed_type, created_at) + select #{contractChatId}, iv.identity_id , #{s3Key}, #{hashKey}, #{signedType},NOW() + FROM contract_chat cc + JOIN identity_verification iv + ON iv.contract_id = cc.contract_chat_id + WHERE cc.contract_chat_id = #{contractChatId} AND iv.user_id= #{userId} + + + + + + UPDATE final_contract + SET contract_pdf_key = #{contractPdfKey}, + contract_pdf_hash = #{contractPdfHash}, + contract_date = NOW(), + contract_expire_date = DATE_ADD(NOW(), INTERVAL 2 YEAR) + WHERE contract_id = #{contractChatId} + + + + INSERT INTO final_contract (contract_id, home_id, owner_id, buyer_id, contract_pdf_key, contract_pdf_hash, contract_date, contract_expire_date) + SELECT + cc.contract_chat_id, cc.home_id, cc.owner_id, cc.buyer_id, - #{contractPdfUrl}, + #{contractPdfKey}, #{contractPdfHash}, - #{contractDate}, - #{contractExpireDate}, - oiv.identity_verified_at, - biv.identity_verified_at, - es.owner_signed_at, - es.buyer_signed_at, - h.deposit_price, - h.monthly_rent, - h.maintenance_fee, - NOW() + NOW(), + DATE_ADD(NOW(), INTERVAL 2 YEAR) FROM contract_chat cc - LEFT JOIN home h ON cc.home_id = h.home_id - LEFT JOIN identity_verification oiv ON oiv.user_id = cc.owner_id AND oiv.contract_id = cc.contract_chat_id - LEFT JOIN identity_verification biv ON biv.user_id = cc.buyer_id AND biv.contract_id = cc.contract_chat_id - LEFT JOIN electronic_signature es ON cc.home_id = fc.home_id AND cc.owner_id = fc.owner_id - WHERE cc.contract_chat_id = #{contractChatId} + WHERE cc.contract_chat_id = #{contractChatId} + ON DUPLICATE KEY UPDATE + contract_pdf_key = VALUES(contract_pdf_key), + contract_pdf_hash = VALUES(contract_pdf_hash), + contract_date = VALUES(contract_date), + contract_expire_date = VALUES(contract_expire_date) + + + + + +