diff --git a/src/main/java/org/scoula/domain/contract/controller/ContractController.java b/src/main/java/org/scoula/domain/contract/controller/ContractController.java index b5d892f2..92f79a00 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractController.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractController.java @@ -1,5 +1,7 @@ package org.scoula.domain.contract.controller; +import javax.servlet.http.HttpServletResponse; + import org.scoula.domain.contract.dto.*; import org.scoula.global.auth.dto.CustomUserDetails; import org.scoula.global.common.dto.ApiResponse; @@ -7,6 +9,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; @@ -114,4 +118,68 @@ ResponseEntity> updateSpecialContract( @PathVariable Long contractChatId, @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody SpecialContractUpdateDTO dto); + + // ================= + + // 내보내기 + // @ApiOperation( + // value = "[내보내기] 1 최종 계약서, 전자서명 테이블 초기 세팅", + // notes = "전자서명에 관련된 값들을 저장하기 위해 테이블 초기 세팅을 합니다.") + // ResponseEntity> finalContractInit( + // @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") String dtoText, // JSON 파트 + @RequestPart("imgFiles") MultipartFile 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) + 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 7c94e640..9a8373ce 100644 --- a/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java +++ b/src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java @@ -1,5 +1,7 @@ package org.scoula.domain.contract.controller; +import javax.servlet.http.HttpServletResponse; + import org.scoula.domain.contract.dto.*; import org.scoula.domain.contract.service.ContractService; import org.scoula.global.auth.dto.CustomUserDetails; @@ -7,6 +9,9 @@ import org.springframework.http.ResponseEntity; 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; @@ -191,4 +196,130 @@ public ResponseEntity> sendStep4( return ResponseEntity.ok( ApiResponse.success(service.sendStep4(contractChatId, userDetails.getUserId()))); } + + @Override + @GetMapping("/finalContract") + public ResponseEntity> selectContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok( + ApiResponse.success( + service.selectContractPDF(contractChatId, userDetails.getUserId()))); + } + + // ======================================== + + // @Override + // @PostMapping("/final_contract") + // public ResponseEntity> finalContractInit( + // @PathVariable Long contractChatId, + // @AuthenticationPrincipal CustomUserDetails userDetails) { + // return ResponseEntity.ok( + // ApiResponse.success( + // service.finalContractInit(contractChatId, + // userDetails.getUserId()))); + // } + + @Override + @GetMapping("/final_contract") + public ResponseEntity> finalContractPDF( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok( + ApiResponse.success( + service.finalContractPDF(contractChatId, userDetails.getUserId()))); + } + + @Override + @PostMapping("/signature/tax") + public ResponseEntity> saveSignature( + @PathVariable Long contractChatId, + @AuthenticationPrincipal CustomUserDetails userDetails, + // @RequestPart("dto") SaveSignatureDTO dto, // JSON 파트 + @RequestParam("dto") String dtoText, + @RequestPart("imgFiles") MultipartFile imgFiles) + throws Exception { + if (imgFiles == null || imgFiles.isEmpty()) { + throw new IllegalArgumentException("서명 이미지가 비어 있습니다."); + } + // 문자열 -> DTO (JSON도, 'TAX' 같은 단일 문자열도 허용) + SaveSignatureDTO dto; + try { + dto = + new ObjectMapper() + .readValue(dtoText, SaveSignatureDTO.class); // {"signedType":"TAX"} + } catch (Exception ignore) { + dto = + SaveSignatureDTO.builder() + .signedType( + org.scoula.domain.contract.enums.SignedType.valueOf( + dtoText.trim().toUpperCase())) + .build(); // TAX + } + 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 + // @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))); + // } + + @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/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..3f49b06e --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/DBFinalContractDTO.java @@ -0,0 +1,46 @@ +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; +} 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..cc5feaf3 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/FindContractDTO.java @@ -0,0 +1,17 @@ +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 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/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..8ad655bc --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/SaveFinalContractDTO.java @@ -0,0 +1,127 @@ +package org.scoula.domain.contract.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +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; // home _ 전세 : True / 월세 : False + private String ownerNickname; // user _ 임대인 이름 + private String buyerNickname; // user_임차인 이름 + + // 임차주택의 표시 + private String addr1; // home _ addr1 + private String landCategory; // home detail _ 토지 지목 + private BigDecimal area; // home detail _ 토지 면적 + + 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; + + // 임차인 이름은 위에 있음 + + 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) + .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()) + .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..b6469fd4 --- /dev/null +++ b/src/main/java/org/scoula/domain/contract/dto/SaveSignatureDTO.java @@ -0,0 +1,27 @@ +package org.scoula.domain.contract.dto; + +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 = "사인 이미지") + // private MultipartFile signatureImg; + + @ApiModelProperty( + value = "어떤 서명인지 ENUM으로 ", + example = "TAX", + allowableValues = " TAX, PRIORITY, OWNER_CONTRACT, BUYER_CONTRACT") + private SignedType signedType; +} 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..b6239c33 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,12 @@ 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, "최종 계약서에 동의가 되지 않아 계약서를 완료할 수 없습니다."); + 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..758727ed 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,40 @@ 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); + + String selectBirth(@Param("userId") Long userId); - 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 ed9f4fa7..29c08013 100644 --- a/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java +++ b/src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java @@ -28,6 +28,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/ContractService.java b/src/main/java/org/scoula/domain/contract/service/ContractService.java index e875661d..339338be 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,9 @@ package org.scoula.domain.contract.service; +import javax.servlet.http.HttpServletResponse; + import org.scoula.domain.contract.dto.*; +import org.springframework.web.multipart.MultipartFile; public interface ContractService { @@ -142,4 +145,81 @@ public interface ContractService { * @param userId 유저 아이디 @Parma step 계약서 단계 */ Void sendStep4(Long contractChatId, Long userId); + + // ============================================ + // /** + // * 최종 계약서, 전자 서명 테이블 초기 세팅 + // * + // * @param contractChatId 채팅방 아이디 + // * @param userId 유저 아이디 + // */ + // Void finalContractInit(Long contractChatId, Long userId); + + /** + * 최종 계약서 작성하기 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, MultipartFile imgFiles) + throws Exception; + + /** + * 최종계약서 S3에 저장 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + Void saveFinalContract(Long contractChatId, Long userId, ContractPasswordDTO dto); + + /** + * 최종 계약서를 불러와서 보내주기 + * + * @param contractChatId 채팅방 아이디 + * @param userId 유저 아이디 + */ + byte[] selectContractPDF(Long contractChatId, Long userId); + + // /** + // * 계약서 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; } diff --git a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java index 0b025deb..d1dd52aa 100644 --- a/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java +++ b/src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java @@ -9,16 +9,23 @@ import org.scoula.domain.chat.vo.ContractChat; 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; @@ -27,102 +34,127 @@ 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.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.List; @Service @RequiredArgsConstructor @Log4j2 public class ContractServiceImpl implements ContractService { - private final ContractChatServiceInterface contractChatService; + private final ContractChatServiceInterface contractChatService; - private final ContractMapper contractMapper; - private final ContractMongoRepository repository; - private final RestTemplate restTemplate; - private final TenantPreContractMapper tenantMapper; - private final ContractChatMapper contractChatMapper; - private final IdentityVerificationService identityVerificationService; + private final ContractMapper contractMapper; + private final ContractMongoRepository repository; + private final RestTemplate restTemplate; + private final TenantPreContractMapper tenantMapper; + private final ContractChatMapper contractChatMapper; + private final IdentityVerificationService identityVerificationService; + private final EncryptionService encryptionService; private final ObjectMapper objectMapper = new ObjectMapper(); - private final RedisTemplate stringRedisTemplate; - private final S3ServiceImpl s3Service; - private final EmailServiceImpl emailService; - - @Value("${ai.server.url:http://localhost:8000}") - private String aiServerUrl; - - /** {@inheritDoc} */ - @Override - public Void saveContractMongo(Long contractChatId, Long userId) { - // userId 검증 - validateIsOwner(contractChatId, userId); - - // 이미 생성된 계약 문서가 있으면 저장 대신 안내 메시지 전송 후 종료 - 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); - } - - // mongoDB에 contract 도큐멘트를 만들어서 저장한다. - ContractMongoDocument document = repository.saveContractMongo(dto, contractEndDate); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_INSERT); - } - return null; - } - - /** {@inheritDoc} */ - // 계약서 조회하기 - @Override - public ContractDTO getContract(Long contractChatId, Long userId) { - - // userId 검증 - validateUserId(contractChatId, userId); - - // id로 Repository에서 값을 찾는다 - ContractMongoDocument document = repository.getContract(contractChatId); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_GET); - } - - Long ownerContractId = contractMapper.getOwnerId(contractChatId); - Long buyerContractId = contractMapper.getBuyerId(contractChatId); - - IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); - IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); - - // 찾은 값을 Dto에 넣고 반환하기 - ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); - - return dto; - } + 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) ==> 환경변수에 넣기 + + @Value("${ai.server.url:http://localhost:8000}") + private String aiServerUrl; + + /** + * {@inheritDoc} + */ + @Override + public Void saveContractMongo(Long contractChatId, Long userId) { + // userId 검증 + validateIsOwner(contractChatId, userId); + + // 이미 생성된 계약 문서가 있으면 저장 대신 안내 메시지 전송 후 종료 + 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); + } + + // mongoDB에 contract 도큐멘트를 만들어서 저장한다. + ContractMongoDocument document = repository.saveContractMongo(dto, contractEndDate); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_INSERT); + } + return null; + } + + /** + * {@inheritDoc} + */ + // 계약서 조회하기 + @Override + public ContractDTO getContract(Long contractChatId, Long userId) { + + // userId 검증 + validateUserId(contractChatId, userId); + + // id로 Repository에서 값을 찾는다 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_GET); + } + + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + // 찾은 값을 Dto에 넣고 반환하기 + ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + + return dto; + } @Override // 해당 스텝 메세지 & 다음 단계로 넘어가는지 @@ -138,11 +170,11 @@ public Void getContractNext(Long contractChatId, Long userId) { contractChatService.AiMessage( contractChatId, """ - 👋🏻 안녕하세요! - 이 계약은 임대인 %s님과 임차인 %s님의 계약입니다. - 시작하기 전, 정보를 먼저 확인할게요. - 제출된 정보를 토대로 계약서를 추출할게요. - """.formatted(aiDto.getOwnerName(), aiDto.getBuyerName()) + 👋🏻 안녕하세요! + 이 계약은 임대인 %s님과 임차인 %s님의 계약입니다. + 시작하기 전, 정보를 먼저 확인할게요. + 제출된 정보를 토대로 계약서를 추출할게요. + """.formatted(aiDto.getOwnerName(), aiDto.getBuyerName()) ); // 2초 대기 @@ -152,12 +184,12 @@ public Void getContractNext(Long contractChatId, Long userId) { Thread.currentThread().interrupt(); } - contractChatService.AiMessageBtn(contractChatId, """ - %s님과 %s님이 작성한 사전 조사를 토대로 - 정보를 추출한 결과가 다음과 같습니다. - 매물 정보, 조건을 확인하셨나요? - 다음 단계로 넘어갈까요? - """.formatted(aiDto.getBuyerName(), aiDto.getOwnerName())); + contractChatService.AiMessageBtn(contractChatId, """ + %s님과 %s님이 작성한 사전 조사를 토대로 + 정보를 추출한 결과가 다음과 같습니다. + 매물 정보, 조건을 확인하셨나요? + 다음 단계로 넘어갈까요? + """.formatted(aiDto.getBuyerName(), aiDto.getOwnerName())); return null; } @@ -201,142 +233,150 @@ public Boolean nextStep(Long contractChatId, Long userId, NextStepDTO dto) { } - /** {@inheritDoc} */ - @Override - public PaymentDTO getDepositPrice(Long contractChatId, Long userId) { - - // userId 검증 - validateUserId(contractChatId, userId); - - ContractMongoDocument doc = repository.getContract(contractChatId); - AIMessageDTO aiDto = AIMessageDTO.toDTO(doc); - - long contract = ChronoUnit.YEARS.between(aiDto.getContractStartDate(), aiDto.getContractEndDate()); - String rentType = tenantMapper.selectRentType(contractChatId, userId) - .orElseThrow(() -> new BusinessException(ContractException.CONTRACT_GET, "전/월세 타입 조회 실패")); - - // 시작 메세지 보내기 - contractChatService.AiMessage( - contractChatId, - """ - 다음은 2단계: ‘금액 조율’ 단계입니다. - - 이 계약은 계약기간 %d년의 %s 계약입니다. - 전세 보증금은 %s, - 관리비는 %s입니다. - """.formatted( - contract, - rentType, - formatWonShort(aiDto.getDepositPrice()), - formatWonShort(aiDto.getMaintenanceFee()))); - - // 대기 - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - contractChatService.AiMessage( - contractChatId, """ - 자유롭게 채팅 후 임대인(%s)님께서 금액을 조정해주세요. 임차인(%s)님이 수락 후 해당 조건의 확정이 가능합니다. - """.formatted(aiDto.getBuyerName(), aiDto.getOwnerName())); - - // MongoDB에서 보증금, 계약금, 잔금, 월세를 조회한다 - ContractMongoDocument document = repository.getDepositPrice(contractChatId); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_GET); - } - - // 조회된 금액을 리턴한다. - PaymentDTO dto = PaymentDTO.toDTO(document); - return dto; - } - - /** {@inheritDoc} */ - @Override - public Void saveDepositPrice(Long contractChatId, Long userId, PaymentDTO dto) { - // Userid 검증 - validateUserId(contractChatId, userId); - - // 레디스에 내용 저장하기 / value 값 넛기 - String redisKey = "contract:payment:" + contractChatId; - try { - // 3. DTO를 JSON 문자열로 변환 - ObjectMapper objectMapper = new ObjectMapper(); - String json = objectMapper.writeValueAsString(dto); - - // 4. Redis에 저장 - stringRedisTemplate.opsForValue().set(redisKey, json); - - } catch (JsonProcessingException e) { - throw new BusinessException(ContractException.CONTRACT_REDIS, e); - } - - return null; - } - - /** {@inheritDoc} */ - @Override - public Void deleteDepositPrice(Long contractChatId, Long userId) { - // userId 검증 - validateUserId(contractChatId, userId); - - // 레디스에 내용 삭제하기 - // Redis key 정의 - String redisKey = "contract:payment:" + contractChatId; - String json = stringRedisTemplate.opsForValue().get(redisKey); - - if (json == null) { - throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); - } - - // Redis에서 삭제 - stringRedisTemplate.delete(redisKey); - - return null; - } - - /** {@inheritDoc} */ - @Override - public Void updateDepositPrice(Long contractChatId, Long userId) { - // Userid 검증 - validateUserId(contractChatId, userId); - - // 2. Redis에서 해당 금액 정보 가져오기 - String redisKey = "contract:payment:" + contractChatId; // value : 임대인 id -> 거절시 Delete - String json = stringRedisTemplate.opsForValue().get(redisKey); - - if (json == null) { - throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); - } - - try { - // 3. JSON -> DTO로 변환 - ObjectMapper objectMapper = new ObjectMapper(); - PaymentDTO dto = objectMapper.readValue(json, PaymentDTO.class); - - // 4. MongoDB에서 계약서 불러오기 - repository.updateDepositPrice(contractChatId, dto); - - // 7. Redis 값 삭제 - stringRedisTemplate.delete(redisKey); - - } catch (Exception e) { - throw new BusinessException(ContractException.CONTRACT_UPDATE, e); - } - - // 스텝 변경 - contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP2); - - // 다음 단계 메세지 보내기 - contractChatService.AiMessage(contractChatId, "이번 단계는 '특약 조율' 단계입니다"); - - return null; - } + /** + * {@inheritDoc} + */ + @Override + public PaymentDTO getDepositPrice(Long contractChatId, Long userId) { + + // userId 검증 + validateUserId(contractChatId, userId); + + ContractMongoDocument doc = repository.getContract(contractChatId); + AIMessageDTO aiDto = AIMessageDTO.toDTO(doc); + + long contract = ChronoUnit.YEARS.between(aiDto.getContractStartDate(), aiDto.getContractEndDate()); + String rentType = tenantMapper.selectRentType(contractChatId, userId) + .orElseThrow(() -> new BusinessException(ContractException.CONTRACT_GET, "전/월세 타입 조회 실패")); + + // 시작 메세지 보내기 + contractChatService.AiMessage( + contractChatId, + """ + 다음은 2단계: ‘금액 조율’ 단계입니다. + + 이 계약은 계약기간 %d년의 %s 계약입니다. + 전세 보증금은 %s, + 관리비는 %s입니다. + """.formatted( + contract, + rentType, + numberFormatUtil.formatWonShort(aiDto.getDepositPrice()), + numberFormatUtil.formatWonShort(aiDto.getMaintenanceFee()))); + + // 대기 + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + contractChatService.AiMessage( + contractChatId, """ + 자유롭게 채팅 후 임대인(%s)님께서 금액을 조정해주세요. 임차인(%s)님이 수락 후 해당 조건의 확정이 가능합니다. + """.formatted(aiDto.getBuyerName(), aiDto.getOwnerName())); + + // MongoDB에서 보증금, 계약금, 잔금, 월세를 조회한다 + ContractMongoDocument document = repository.getDepositPrice(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_GET); + } + + // 조회된 금액을 리턴한다. + PaymentDTO dto = PaymentDTO.toDTO(document); + return dto; + } + + /** + * {@inheritDoc} + */ + @Override + public Void saveDepositPrice(Long contractChatId, Long userId, PaymentDTO dto) { + // Userid 검증 + validateUserId(contractChatId, userId); + + // 레디스에 내용 저장하기 / value 값 넛기 + String redisKey = "contract:payment:" + contractChatId; + try { + // 3. DTO를 JSON 문자열로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(dto); + + // 4. Redis에 저장 + stringRedisTemplate.opsForValue().set(redisKey, json); + + } catch (JsonProcessingException e) { + throw new BusinessException(ContractException.CONTRACT_REDIS, e); + } + + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public Void deleteDepositPrice(Long contractChatId, Long userId) { + // userId 검증 + validateUserId(contractChatId, userId); + + // 레디스에 내용 삭제하기 + // Redis key 정의 + String redisKey = "contract:payment:" + contractChatId; + String json = stringRedisTemplate.opsForValue().get(redisKey); + + if (json == null) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); + } + + // Redis에서 삭제 + stringRedisTemplate.delete(redisKey); + + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public Void updateDepositPrice(Long contractChatId, Long userId) { + // Userid 검증 + validateUserId(contractChatId, userId); + + // 2. Redis에서 해당 금액 정보 가져오기 + String redisKey = "contract:payment:" + contractChatId; // value : 임대인 id -> 거절시 Delete + String json = stringRedisTemplate.opsForValue().get(redisKey); + + if (json == null) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "금액 정보가 Redis에 없습니다."); + } + + try { + // 3. JSON -> DTO로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + PaymentDTO dto = objectMapper.readValue(json, PaymentDTO.class); + // 4. MongoDB에서 계약서 불러오기 + repository.updateDepositPrice(contractChatId, dto); - // 적법성 검사 + // 7. Redis 값 삭제 + stringRedisTemplate.delete(redisKey); + + } catch (Exception e) { + throw new BusinessException(ContractException.CONTRACT_UPDATE, e); + } + + // 스텝 변경 + contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP2); + + // 다음 단계 메세지 보내기 + contractChatService.AiMessage(contractChatId, "이번 단계는 '특약 조율' 단계입니다"); + + return null; + } + + + // 적법성 검사 // @Override // public ContractDTO getContracts (Long contractChatId, Long userId){ // // userId 검증 @@ -381,77 +421,82 @@ public Void updateDepositPrice(Long contractChatId, Long userId) { @Override public Void saveSpecialContract(Long contractChatId, Long userId) { - // userId 검증 - validateUserId(contractChatId, userId); - // 몽고 DB에서 특약부분을 받아서 저장한다. - try { repository.saveSpecialContract(contractChatId); } catch (Exception e) { - // 예외 로그 기록 및 사용자에게 전달할 메시지 등 처리 - log.error("특약사항 저장 실패 ❌", e); - throw new BusinessException(ContractException.CONTRACT_INSERT, e); } - return null; - } - - /** {@inheritDoc} */ - // ai로 적법성 검사하기 -> 암호화 풀어서 보내기 - @Override - public LegalityDTO getLegality(Long contractChatId, Long userId) { - // userId 검증 - validateUserId(contractChatId, userId); - - // MongoDB에서 전체 부분을 조회한다 - ContractMongoDocument document = repository.getContract(contractChatId); - if (document == null) { - throw new BusinessException(ContractException.CONTRACT_GET); - } - - Long ownerContractId = contractMapper.getOwnerId(contractChatId); - Long buyerContractId = contractMapper.getBuyerId(contractChatId); - IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); - IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); - - ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); - - // AI - try { - // AI로 해당 데이터를 넘긴다 (restTemplate 사용) - String url = aiServerUrl + "/api/contract/validate"; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity requestEntity = new HttpEntity<>(dto, headers); - - // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class); - LegalityDTO res = response.getBody(); - assert res != null; - log.warn("AI 응답 값 확인: {}", res.toString()); - - log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return response.getBody(); - } else { - // Sanitize response body before logging to prevent log injection - String responseBodyStr; - try { - ObjectMapper objectMapper = new ObjectMapper(); - responseBodyStr = objectMapper.writeValueAsString(response.getBody()); - } catch (Exception ex) { - responseBodyStr = String.valueOf(response.getBody()); - } - // Remove newlines and carriage returns - responseBodyStr = responseBodyStr.replaceAll("[\\r\\n]", " "); - log.error(responseBodyStr); - throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR); - } - - } catch (Exception e) { - log.error(e.getMessage()); - throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); - } - } - - // 임대인 삭제 + // userId 검증 + validateUserId(contractChatId, userId); + // 몽고 DB에서 특약부분을 받아서 저장한다. + try { + repository.saveSpecialContract(contractChatId); + } catch (Exception e) { + // 예외 로그 기록 및 사용자에게 전달할 메시지 등 처리 + log.error("특약사항 저장 실패 ❌", e); + throw new BusinessException(ContractException.CONTRACT_INSERT, e); + } + return null; + } + + /** + * {@inheritDoc} + */ + // ai로 적법성 검사하기 -> 암호화 풀어서 보내기 + @Override + public LegalityDTO getLegality(Long contractChatId, Long userId) { + // userId 검증 + validateUserId(contractChatId, userId); + + // MongoDB에서 전체 부분을 조회한다 + ContractMongoDocument document = repository.getContract(contractChatId); + if (document == null) { + throw new BusinessException(ContractException.CONTRACT_GET); + } + + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + IdentityVerificationInfoVO ownerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, ownerContractId); + IdentityVerificationInfoVO buyerVO = identityVerificationService.getDecryptedVerificationInfo(contractChatId, buyerContractId); + + ContractDTO dto = ContractDTO.toDTO(document, ownerVO, buyerVO); + + // AI + try { + // AI로 해당 데이터를 넘긴다 (restTemplate 사용) + String url = aiServerUrl + "/api/contract/validate"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity requestEntity = new HttpEntity<>(dto, headers); + + // 반환값을 받아오고, 그 값을 프론트에 넘겨준다. + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class); + LegalityDTO res = response.getBody(); + assert res != null; + log.warn("AI 응답 값 확인: {}", res.toString()); + + log.warn("AI 응답 헤더 확인: {}", response.getStatusCode()); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return response.getBody(); + } else { + // Sanitize response body before logging to prevent log injection + String responseBodyStr; + try { + ObjectMapper objectMapper = new ObjectMapper(); + responseBodyStr = objectMapper.writeValueAsString(response.getBody()); + } catch (Exception ex) { + responseBodyStr = String.valueOf(response.getBody()); + } + // Remove newlines and carriage returns + responseBodyStr = responseBodyStr.replaceAll("[\\r\\n]", " "); + log.error(responseBodyStr); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR); + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } + + // 임대인 삭제 @Override @Transactional public String deleteOwnerLegality(Long contractChatId, Long userId) { @@ -482,42 +527,42 @@ public String deleteOwnerLegality(Long contractChatId, Long userId) { // 임대인 수정요청 @Override @Transactional - public Void updateOwnerLegality(Long contractChatId, Long userId, UpdateLegalityDTO updateLegalityDTO) { + public Void updateOwnerLegality(Long contractChatId, Long userId, UpdateLegalityDTO updateLegalityDTO) { - // userId 검증 - validateUserId(contractChatId, userId); + // userId 검증 + validateUserId(contractChatId, userId); Long ownerContractId = contractMapper.getOwnerId(contractChatId); - String redisKey = "final-contract:legality:" + contractChatId + ":" + ownerContractId; - - String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); - if (existingRequest != null) { - throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); - } - - LegalityRequestDTO requestData = - LegalityRequestDTO.builder() - .legalBasis(updateLegalityDTO.getLegalBasis()) - .requestId(userId) - .createdAt(LocalDateTime.now().toString()) - .build(); - try { - String jsonData = objectMapper.writeValueAsString(requestData); - // Store as valid JSON for correct parsing later - String valueData = String.format("{\"requestData\":%s}", jsonData); - stringRedisTemplate.opsForValue().set(redisKey, valueData); - - contractChatService.AiMessage(contractChatId, "임대인이 적법성 검사 수정을 요청합니다."); - } catch (Exception e) { - log.error("수정 요청 저장 실패", e); - throw new RuntimeException("수정 요청 저장 중 오류가 발생했습니다."); - } - - return null; - } + String redisKey = "final-contract:legality:" + contractChatId + ":" + ownerContractId; + + String existingRequest = stringRedisTemplate.opsForValue().get(redisKey); + if (existingRequest != null) { + throw new IllegalArgumentException("해당 조항에 대한 수정 요청이 이미 대기중입니다."); + } + + LegalityRequestDTO requestData = + LegalityRequestDTO.builder() + .legalBasis(updateLegalityDTO.getLegalBasis()) + .requestId(userId) + .createdAt(LocalDateTime.now().toString()) + .build(); + try { + String jsonData = objectMapper.writeValueAsString(requestData); + // Store as valid JSON for correct parsing later + String valueData = String.format("{\"requestData\":%s}", jsonData); + stringRedisTemplate.opsForValue().set(redisKey, valueData); + + contractChatService.AiMessage(contractChatId, "임대인이 적법성 검사 수정을 요청합니다."); + } catch (Exception e) { + log.error("수정 요청 저장 실패", e); + throw new RuntimeException("수정 요청 저장 중 오류가 발생했습니다."); + } + + return null; + } // 임차인 수정 - @Override + @Override @Transactional public Void updateBuyerLegality(Long contractChatId, Long userId, SpecialContractUpdateDTO dto) { @@ -533,7 +578,7 @@ public Void updateBuyerLegality(Long contractChatId, Long userId, SpecialContrac throw new IllegalArgumentException("대기중인 수정 요청이 없습니다."); } - try{ + try { // JSON에서 clauseOrder와 requestData 추출 com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(valueDataJson); String requestDataJson = rootNode.get("requestData").toString(); @@ -550,7 +595,7 @@ public Void updateBuyerLegality(Long contractChatId, Long userId, SpecialContrac stringRedisTemplate.delete(redisKey); contractChatService.AiMessage(contractChatId, resultMessage); - }catch (Exception e) { + } catch (Exception e) { log.error("수정 요청 응답 처리 실패", e); throw new RuntimeException("응답 처리 중 오류가 발생했습니다."); } @@ -587,73 +632,804 @@ public String rejectBuyerLegality(Long contractChatId, Long userId) { return "임대인이 적법성 수정을 거절했습니다."; } - /** {@inheritDoc} */ - // 수정 확정 - @Override - public Void updateSpecialContract(Long contractChatId, Long userId, SpecialContractUpdateDTO dto) { - // userId 검증 - validateUserId(contractChatId, userId); - - // 해당 번호에 맞는 특약을 계약서 몽고 DB에 update해서 수정한다. - try { - repository.updateSpecialContract(contractChatId, dto); - } catch (Exception e) { - throw new BusinessException(ContractException.CONTRACT_UPDATE); - } - return null; - } - - /** {@inheritDoc} */ - @Override - public Void sendStep4(Long contractChatId, Long userId) { - - // userId 검증 - validateUserId(contractChatId, userId); - - contractChatService.AiMessage(contractChatId, "계약서 작성이 완료되었습니다."); - - return null; - } - - // --------------------------------------- - // Userid 검증 - public void validateUserId(Long contractChatId, Long userId) { - - if (userId == null) { - throw new BusinessException(PreContractErrorCode.TENANT_USER); - } - - Long ownerContractId = contractMapper.getOwnerId(contractChatId); - Long buyerContractId = contractMapper.getBuyerId(contractChatId); - - if (userId.equals(ownerContractId)) { - validateIsOwner(contractChatId, userId); - return; - } - - if (userId.equals(buyerContractId)) { - Long buyerId = tenantMapper - .selectContractBuyerId(contractChatId) - .orElseThrow(() -> new BusinessException(PreContractErrorCode.TENANT_USER)); - - if (!userId.equals(buyerId)) { - throw new BusinessException(PreContractErrorCode.TENANT_USER); - } - return; - } - - throw new BusinessException(PreContractErrorCode.TENANT_USER); - } - - public void validateIsOwner(Long contractChatId, Long userId) { - Long ownerId= - tenantMapper.selectContractOwnerId(contractChatId).orElseThrow(() -> new BusinessException(PreContractErrorCode.TENANT_USER)); - if (!userId.equals(ownerId)) { - throw new BusinessException(PreContractErrorCode.TENANT_USER); - } - } - - public Boolean nextSteps(Long contractChatId, Long userId, NextStepDTO dto) { + /** + * {@inheritDoc} + */ + // 수정 확정 + @Override + public Void updateSpecialContract(Long contractChatId, Long userId, SpecialContractUpdateDTO dto) { + // userId 검증 + validateUserId(contractChatId, userId); + + // 해당 번호에 맞는 특약을 계약서 몽고 DB에 update해서 수정한다. + try { + repository.updateSpecialContract(contractChatId, dto); + } catch (Exception e) { + throw new BusinessException(ContractException.CONTRACT_UPDATE); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public Void sendStep4(Long contractChatId, Long userId) { + + // userId 검증 + validateUserId(contractChatId, userId); + + contractChatService.AiMessage(contractChatId, "계약서 작성이 완료되었습니다."); + + return null; + } + + // --------------------------------------- + // ======================================================================== + + // final_contract 초기 세팅 +// @Override +// @Transactional +// public Void finalContractInit(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); +// +// return null; +// } + + // 최종 계약서 작성하기 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, MultipartFile imgFiles) throws Exception { + // userId 인증 + validateUserId(contractChatId, userId); + + if (signatureDTO == null || signatureDTO.getSignedType() == null) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "signedType이 비었습니다."); + } + if (imgFiles == null || imgFiles.isEmpty()) { + throw new BusinessException(ContractException.CONTRACT_REDIS, "서명 이미지가 비어 있습니다."); + } + + log.info("[saveSignature] start ccId={}, userId={}, signedType={}, fileName={}, size={}", + contractChatId, userId, signatureDTO.getSignedType(), + imgFiles.getOriginalFilename(), imgFiles.getSize()); + + // 사진 암호화 & 해시값 생성 + FileWithHashDto imgDTO = encryptionService.encryptImage(imgFiles); + +// 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 Void saveFinalContract(Long contractChatId, Long userId, ContractPasswordDTO dto) { + // userId 인증 + validateUserId(contractChatId, userId); + + // 동의 여부 확인 + if (!dto.getMediationAgree()) throw new BusinessException(ContractException.CONTRACT_AGREEMENT); + + // 1. 최종 사인이 있는지 여부를 확인한다. + List signatures = contractMapper.selectSignature(contractChatId, userId); + + // DTO에 값 넣기 + // 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 { + 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); + } + + // AI 쪽에서 최종 값을 받기 (pdf) + MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); + + try { + // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) + encryptionService.uploadPdfStep1(contracts, String.valueOf(contractChatId), dto.getContractPassword()); + } 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 (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); + } + } + + } 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) { + + // 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 { + 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); + } + + // AI 쪽에서 최종 값을 받기 (pdf) + MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); + + try { + // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) + encryptionService.uploadPdfStep1(contracts, String.valueOf(contractChatId), dto.getContractPassword()); + } 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 (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); + } + } + + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR, e); + } + } + } + + + // ================================= + + +// // S3에서 파일 가져오기 +// FinalContract s3Key = contractMapper.selectFinalContract(contractChatId); +// InputStream contract = s3Service.downloadFile(s3Key.getContractPdfKey()); +// File contractFile; +// try { +// // 임시 파일 생성 (필요하다면 builder 등에 전달) +// contractFile = MultipartFileUtils.inputStreamToTempFile(contract); +// } catch (IOException e) { +// // 예외를 로깅하고 비즈니스 예외로 감싸서 위로 던집니다. +// log.error("임시 파일 생성 중 오류 발생", e); +// throw new BusinessException(ContractException.CONTRACT_INSERT, e); +// } +// +// // 동의 여부까지 같은 빌더에 누적 후 최종 DTO 생성 +// FinalContractDTO finalDTO = builder +// .mediationAgree(dto.getMediationAgree()) +// .contractPDF(contractFile) +// .build(); +// +// File tempFile; +// // AI에 사인 & 동의 여부를 넘기기 -> 여기서 pdf를 같이 넘겨야 하는지 or 다시 처음부터 모든 값을 넘겨야 하는지 물어보기 +// 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(); +// tempFile = File.createTempFile("contract_", ".pdf"); +// Files.write(tempFile.toPath(), fileBytes); +// +// } 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); +// } +// +// // AI 쪽에서 최종 값을 받기 (pdf) +// MultipartFile contracts = MultipartFileUtils.fromFile(tempFile); +// +// // 받은 비밀번호 & 서버키로 암호화 및 해시키를 받기 +// // 레디스에 내용 저장하기 / value 값 넛기 +// String redisKey = "contract:export:" + contractChatId; +// 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); +// +// try { +// // 1단계 업로드 실행 (반환 값을 사용하지 않으면 변수에 담지 않아도 됩니다) +// encryptionService.uploadPdfStep1(contracts, String.valueOf(contractChatId), dto.getContractPassword()); +// } catch (Exception ex) { +// // 업로드 과정의 예외를 비즈니스 예외로 변환 +// throw new BusinessException(ContractException.CONTRACT_INSERT, ex); +// } +// } 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); +// } catch (Exception ex) { +// // 업로드 과정의 예외를 비즈니스 예외로 변환 +// throw new BusinessException(ContractException.CONTRACT_INSERT, ex); +// } +// stringRedisTemplate.delete(redisKey); +// } +// } catch (JsonProcessingException e) { +// throw new BusinessException(ContractException.CONTRACT_REDIS, e); +// } + + return null; + } + + @Override + public byte[] selectContractPDF(Long contractChatId, Long userId) { + // s3에서 pdf를 가져온다. + // 복호화를 한다 + + 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); + + InputStream s3Contract = s3Service.downloadFile(key.getContractPdfKey()); + + MultipartFile files = MultipartFileUtils.inputStreamToMultipartFile(s3Contract); + + // PDF 복호화 하기 + File finalContract = encryptionService.decryptPdf(files, dto.getContractPassword(), key.getContractPdfHash()); + + // 실제 파일명 -> 내가 원하는 파일명 넣어서 보내기 + String originalName = "contract.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); + + InputStream s3Contract = s3Service.downloadFile(key.getContractPdfKey()); + + MultipartFile files = MultipartFileUtils.inputStreamToMultipartFile(s3Contract); + + // PDF 복호화 하기 + File finalContract = encryptionService.decryptPdf(files, dto.getContractPassword(), key.getContractPdfHash()); + + MultipartFile finalFile = MultipartFileUtils.fromFile(finalContract); + + // PDF에 비밀번호 걸어서 보내기 (생년월일?) + String password = contractMapper.selectBirth(userId).replace("-", ""); + + FileWithHashDto pdfContract = encryptionService.addPasswordToPdf(finalFile, password); + + // 이메일 가져오기 + String email = contractMapper.selectMail(userId); + String subject = "계약서 PDF를 보내드립니다."; + String text = "요청하신 계약서입니다."; + + 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; + } + + // =================================================== + + // Userid 검증 + public void validateUserId (Long contractChatId, Long userId){ + + if (userId == null) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + + Long ownerContractId = contractMapper.getOwnerId(contractChatId); + Long buyerContractId = contractMapper.getBuyerId(contractChatId); + + if (userId.equals(ownerContractId)) { + validateIsOwner(contractChatId, userId); + return; + } + + if (userId.equals(buyerContractId)) { + Long buyerId = tenantMapper + .selectContractBuyerId(contractChatId) + .orElseThrow(() -> new BusinessException(PreContractErrorCode.TENANT_USER)); + + if (!userId.equals(buyerId)) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + return; + } + + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + + public void validateIsOwner (Long contractChatId, Long userId){ + Long ownerId = + tenantMapper.selectContractOwnerId(contractChatId).orElseThrow(() -> new BusinessException(PreContractErrorCode.TENANT_USER)); + if (!userId.equals(ownerId)) { + throw new BusinessException(PreContractErrorCode.TENANT_USER); + } + } + + public Boolean nextSteps (Long contractChatId, Long userId, NextStepDTO dto){ ContractChat.ContractStatus step = contractChatMapper.getStatus(contractChatId); // Redis Key: 계약별 step 상태를 저장 String redisKey = String.format("contract:%s:%d", step.name(), contractChatId); @@ -692,30 +1468,5 @@ public Boolean nextSteps(Long contractChatId, Long userId, NextStepDTO dto) { } } - private static String formatWonShort(int amount) { - if (amount == 0) return "0원"; - long eok = amount / 100_000_000; // 억 - long man = (amount % 100_000_000) / 10_000; // 만원 단위 - - StringBuilder sb = new StringBuilder(); - if (eok > 0) { - sb.append(eok).append("억"); - long cheon = man / 1000; // 천만원 단위 - long remainMan = man % 1000; - if (cheon > 0) sb.append(" ").append(cheon).append("천"); - if (cheon == 0 && remainMan > 0) sb.append(" ").append(remainMan).append("만"); - 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+", " "); - } } + 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/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..5fe1cc26 --- /dev/null +++ b/src/main/java/org/scoula/global/common/util/MultipartFileUtils.java @@ -0,0 +1,155 @@ +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 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/resources/org/scoula/domain/contract/mapper/ContractMapper.xml b/src/main/resources/org/scoula/domain/contract/mapper/ContractMapper.xml index 10aa8332..d8a69411 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,147 @@ 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 = , + WHERE contractChatId = #{contractChatId} + + + + + +