diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatMessageReference.java b/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatMessageReference.java index 4049decf..7a4aad1e 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatMessageReference.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatMessageReference.java @@ -16,11 +16,11 @@ public class ChatMessageReference { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_message_id", nullable = false) private ChatMessage chatMessage; - @Column(name = "path", nullable = true) + @Column(name = "path", nullable = false) private String path; @Column(name = "created_at") diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatMessageRequest.java b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatMessageRequest.java index d28965db..b7ecd625 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatMessageRequest.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatMessageRequest.java @@ -3,6 +3,7 @@ import com.deepdirect.deepwebide_be.chat.domain.ChatMessageType; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,6 +13,7 @@ @Schema(description = "채팅 메시지 전송 요청") public class ChatMessageRequest { + @NotBlank(message = "메시지는 공백일 수 없습니다.") @Schema(description = "메시지 내용", example = "코드 이부분 이상한 것 같아") private String message; diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/dto/response/ChatMessageResponse.java b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/response/ChatMessageResponse.java index 4d83e9ed..87221b30 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/dto/response/ChatMessageResponse.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/response/ChatMessageResponse.java @@ -5,7 +5,6 @@ import lombok.Getter; import java.time.LocalDateTime; -import java.util.List; @Getter @Builder @@ -27,8 +26,8 @@ public class ChatMessageResponse { @Schema(description = "메시지 본문") private final String message; - @Schema(description = "코드 참조 목록") - private final List codeReferences; + @Schema(description = "코드 참조") + private final CodeReferenceResponse codeReference; @Schema(description = "내 메시지 여부", name = "IsMine") private final boolean isMine; diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageService.java b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageService.java index 8c6ebd46..3433cf84 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageService.java @@ -26,6 +26,8 @@ import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -75,10 +77,14 @@ public ChatMessagesResponse getMessages(Long repositoryId, Long userId, Long bef // 참조 메시지 조회 List messageIds = messages.stream().map(ChatMessage::getId).toList(); - Map> referenceMap = referenceRepository + Map referenceMap = referenceRepository .findByChatMessageIdIn(messageIds) .stream() - .collect(Collectors.groupingBy(ref -> ref.getChatMessage().getId())); + .collect(Collectors.toMap( + ref -> ref.getChatMessage().getId(), + Function.identity() + )); + // 응답 변환 List responses = messages.stream() @@ -88,11 +94,9 @@ public ChatMessagesResponse getMessages(Long repositoryId, Long userId, Long bef .senderNickname(msg.getSender().getNickname()) .senderProfileImageUrl(msg.getSender().getProfileImageUrl()) .message(msg.getMessage()) - .codeReferences(referenceMap - .getOrDefault(msg.getId(), List.of()) - .stream() + .codeReference(Optional.ofNullable(referenceMap.get(msg.getId())) .map(CodeReferenceResponse::from) - .toList()) + .orElse(null)) .isMine(msg.getSender().getId().equals(userId)) .sentAt(msg.getSentAt()) .build()) @@ -121,9 +125,11 @@ public ChatMessageSearchResponse searchMessages(Long repositoryId, Long userId, long total = chatMessageRepository.countByRepositoryIdAndMessageContainingIgnoreCase(repositoryId, keyword); - Map> ref = referenceRepository + Map ref = referenceRepository .findByChatMessageIdIn(result.stream().map(ChatMessage::getId).toList()) - .stream().collect(Collectors.groupingBy(r -> r.getChatMessage().getId())); + .stream() + .collect(Collectors.toMap(r -> r.getChatMessage().getId(), Function.identity() + )); List responses = result.stream().map(msg -> ChatMessageResponse.builder() @@ -132,9 +138,9 @@ public ChatMessageSearchResponse searchMessages(Long repositoryId, Long userId, .senderNickname(msg.getSender().getNickname()) .senderProfileImageUrl(msg.getSender().getProfileImageUrl()) .message(msg.getMessage()) - .codeReferences(ref.getOrDefault(msg.getId(), List.of()) - .stream().map(CodeReferenceResponse::from) - .toList()) + .codeReference(Optional.ofNullable(ref.get(msg.getId())) + .map(CodeReferenceResponse::from) + .orElse(null)) .isMine(msg.getSender().getId().equals(userId)) .sentAt(msg.getSentAt()) .build() diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageWriteService.java b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageWriteService.java index bfd9075b..a475c333 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageWriteService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatMessageWriteService.java @@ -45,6 +45,10 @@ public ChatMessageBroadcast saveChatMessage(Long userId, Long repositoryId, Stri User sender = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(USER_NOT_FOUND)); + if (content == null || content.isEmpty()) { + throw new GlobalException(EMPTY_CHAT_MESSAGE); + } + ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(repository, sender, content) ); diff --git a/src/main/java/com/deepdirect/deepwebide_be/file/controller/FileController.java b/src/main/java/com/deepdirect/deepwebide_be/file/controller/FileController.java index d6990ff0..32e9d77a 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/file/controller/FileController.java +++ b/src/main/java/com/deepdirect/deepwebide_be/file/controller/FileController.java @@ -10,10 +10,12 @@ import com.deepdirect.deepwebide_be.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; 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 java.util.List; @@ -111,4 +113,15 @@ public ResponseEntity> saveFileContent( ); return ResponseEntity.ok(ApiResponseDto.of(200, "파일 내용 저장 완료했습니다.", response)); } + + @PostMapping("/{repositoryId}/files/upload") + public ResponseEntity> uploadFile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long repositoryId, + @RequestParam("parentId") @NotNull Long parentId, // 필수 지정 + @RequestParam("file") MultipartFile file + ) { + FileNodeResponse response = fileService.uploadFile(repositoryId, userDetails.getId(), parentId, file); + return ResponseEntity.ok(ApiResponseDto.of(201, "파일 업로드 완료", response)); + } } diff --git a/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileCreateRequest.java b/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileCreateRequest.java index c1b555ce..ef819b0e 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileCreateRequest.java +++ b/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileCreateRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Getter; @Getter @@ -16,6 +17,7 @@ public class FileCreateRequest { @NotBlank(message = "파일 타입은 필수입니다.") private String fileType; - @Schema(description = "부모 폴더 ID (최상위면 null)", example = "1") - private Long parentId; // null 허용 -> 검증 X + @Schema(description = "부모 폴더 ID (필수)", example = "1") + @NotNull(message = "부모 폴더 ID는 필수입니다.") // 추가 + private Long parentId; } diff --git a/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileMoveRequest.java b/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileMoveRequest.java index 3fba6791..995d5b8b 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileMoveRequest.java +++ b/src/main/java/com/deepdirect/deepwebide_be/file/dto/request/FileMoveRequest.java @@ -1,6 +1,7 @@ package com.deepdirect.deepwebide_be.file.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; @@ -9,7 +10,7 @@ @Schema(description = "파일 또는 폴더 이동 요청 DTO") public class FileMoveRequest { - @Schema(description = "이동할 폴더 ID (최상위면 null) ", example = "1") + @Schema(description = "이동할 폴더 ID (필수) ", example = "1") + @NotNull(message = "이동할 폴더 ID는 필수입니다.") // 추가 private Long newParentId; - } diff --git a/src/main/java/com/deepdirect/deepwebide_be/file/service/FileContentSyncService.java b/src/main/java/com/deepdirect/deepwebide_be/file/service/FileContentSyncService.java index 7be689b5..2973eb9f 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/file/service/FileContentSyncService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/file/service/FileContentSyncService.java @@ -22,7 +22,7 @@ public class FileContentSyncService { private final FileContentRepository fileContentRepository; private final FileNodeRepository fileNodeRepository; - @Scheduled(fixedRate = 60000) // 1분마다 실행 + @Scheduled(fixedRate = 30000) // 30초마다 실행 public void syncRedisToDb() { Set dirtyFileKeys = redisTemplate.opsForSet().members(DIRTY_SET_KEY); if (dirtyFileKeys == null) return; diff --git a/src/main/java/com/deepdirect/deepwebide_be/file/service/FileService.java b/src/main/java/com/deepdirect/deepwebide_be/file/service/FileService.java index 0592cfda..dc994a40 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/file/service/FileService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/file/service/FileService.java @@ -14,7 +14,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.*; @RequiredArgsConstructor @@ -63,6 +65,15 @@ public List getFileTree(Long repositoryId, Long userId) { @Transactional public FileNodeResponse createFileOrFolder(Long repositoryId, Long userId, FileCreateRequest req) { + + if (req.getParentId() == null) { + throw new GlobalException(ErrorCode.PARENT_ID_REQUIRED); + } + + if ("FILE".equals(req.getFileType())) { + validateFileNameHasExtension(req.getFileName()); + } + // 1. 레포 권한 체크 Repository repo = repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); @@ -123,6 +134,10 @@ public FileRenameResponse renameFileOrFolder(Long repositoryId, Long fileId, Lon .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); FileNode fileNode = findFileNodeWithRepositoryCheck(repositoryId, fileId); + if (fileNode.getFileType() == FileType.FILE) { + validateFileNameHasExtension(newFileName); + } + // 2. 같은 폴더 내에 동일 이름 존재 체크 Long parentId = (fileNode.getParent() == null) ? null : fileNode.getParent().getId(); boolean isDuplicate = fileNodeRepository.existsByRepositoryIdAndParentIdAndName( @@ -190,6 +205,11 @@ private void deleteChildrenRecursively(FileNode parent) { @Transactional public FileNodeResponse moveFileOrFolder( Long repositoryId, Long fileId, Long userId, Long newParentId) { + + if (newParentId == null) { + throw new GlobalException(ErrorCode.PARENT_ID_REQUIRED); + } + repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); FileNode fileNode = findFileNodeWithRepositoryCheck(repositoryId, fileId); @@ -197,17 +217,15 @@ public FileNodeResponse moveFileOrFolder( // 새 부모 폴더 체크 FileNode newParent = null; String newParentPath = ""; - if (newParentId != null) { - newParent = findFileNodeWithRepositoryCheck(repositoryId, newParentId); - if (!newParent.isFolder()) { - throw new GlobalException(ErrorCode.INVALID_PARENT_TYPE); - } - // 순환구조 방지 (본인 또는 하위로 이동 불가) - if (isDescendant(fileNode, newParent)) { - throw new GlobalException(ErrorCode.CANNOT_MOVE_TO_CHILD); - } - newParentPath = newParent.getPath(); + newParent = findFileNodeWithRepositoryCheck(repositoryId, newParentId); + if (!newParent.isFolder()) { + throw new GlobalException(ErrorCode.INVALID_PARENT_TYPE); } + // 순환구조 방지 (본인 또는 하위로 이동 불가) + if (isDescendant(fileNode, newParent)) { + throw new GlobalException(ErrorCode.CANNOT_MOVE_TO_CHILD); + } + newParentPath = newParent.getPath(); // 같은 폴더에 동일 이름 체크 if (fileNodeRepository.existsByRepositoryIdAndParentAndName( @@ -226,7 +244,7 @@ public FileNodeResponse moveFileOrFolder( .fileId(fileNode.getId()) .fileName(fileNode.getName()) .fileType(fileNode.getFileType().name()) - .parentId(newParent == null ? null : newParent.getId()) + .parentId(newParent.getId()) .path(fileNode.getPath()) .build(); } @@ -247,17 +265,26 @@ public FileContentResponse getFileContent(Long repositoryId, Long fileId, Long u .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); FileNode fileNode = findFileNodeWithRepositoryCheck(repositoryId, fileId); - // 폴더는 열 수 없음 if (fileNode.getFileType() == FileType.FOLDER) { throw new GlobalException(ErrorCode.CANNOT_OPEN_FOLDER); } - // 파일 내용 조회 FileContent fileContent = fileContentRepository.findByFileNode(fileNode) .orElseThrow(() -> new GlobalException(ErrorCode.FILE_CONTENT_NOT_FOUND)); - // byte[] → String 변환 (UTF-8) - String content = new String(fileContent.getContent(), java.nio.charset.StandardCharsets.UTF_8); + // 확장자 체크 + String fileName = fileNode.getName(); + String extension = ""; + int idx = fileName.lastIndexOf('.'); + if (idx > 0) extension = fileName.substring(idx + 1).toLowerCase(); + + String content; + // 이미지/바이너리면 Base64, 텍스트면 UTF-8 + if (List.of("png", "jpg", "jpeg", "gif", "svg").contains(extension)) { + content = Base64.getEncoder().encodeToString(fileContent.getContent()); + } else { + content = new String(fileContent.getContent(), java.nio.charset.StandardCharsets.UTF_8); + } return FileContentResponse.builder() .fileId(fileNode.getId()) @@ -302,4 +329,76 @@ private FileNode findFileNodeWithRepositoryCheck(Long repositoryId, Long fileId) } return fileNode; } + + + @Transactional + public FileNodeResponse uploadFile(Long repositoryId, Long userId, Long parentId, MultipartFile file) { + + if (parentId == null) { + throw new GlobalException(ErrorCode.PARENT_ID_REQUIRED); + } + + String fileName = file.getOriginalFilename(); + validateFileNameHasExtension(fileName); + + + // 1. 권한/레포 체크 + Repository repo = repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + // 2. 부모 폴더 체크 + FileNode parent = null; + String parentPath = ""; + parent = findFileNodeWithRepositoryCheck(repositoryId, parentId); + if (!parent.getFileType().equals(FileType.FOLDER)) { + throw new GlobalException(ErrorCode.INVALID_PARENT_TYPE); + } + parentPath = parent.getPath(); + + // 3. 중복 이름 체크 + if (fileNodeRepository.existsByRepositoryIdAndParentIdAndName( + repositoryId, parentId, file.getOriginalFilename())) { + throw new GlobalException(ErrorCode.DUPLICATE_FILE_NAME); + } + + // 4. 경로 계산 + String newPath = parentPath.isEmpty() ? file.getOriginalFilename() : parentPath + "/" + file.getOriginalFilename(); + + // 5. FileNode 생성 + FileNode fileNode = FileNode.builder() + .repository(repo) + .name(file.getOriginalFilename()) + .fileType(FileType.FILE) + .parent(parent) + .path(newPath) + .build(); + fileNode = fileNodeRepository.save(fileNode); + + // 6. 파일 내용 저장 + try { + FileContent content = FileContent.builder() + .fileNode(fileNode) + .content(file.getBytes()) + .build(); + fileContentRepository.save(content); + } catch (IOException e) { + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAIL); + } + + // 7. 응답 반환 + return FileNodeResponse.builder() + .fileId(fileNode.getId()) + .fileName(fileNode.getName()) + .fileType("FILE") + .parentId(parent.getId()) + .path(fileNode.getPath()) + .build(); + } + + private void validateFileNameHasExtension(String fileName) { + if (fileName == null || !fileName.contains(".") || fileName.startsWith(".") || fileName.endsWith(".")) { + throw new GlobalException(ErrorCode.FILE_EXTENSION_REQUIRED); + } + } + } diff --git a/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java b/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java index 3ac53a93..7bb9ae7f 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java +++ b/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java @@ -15,6 +15,7 @@ public enum ErrorCode { REPOSITORY_MEMBER_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "최대 인원이 초과되어 입장할 수 없습니다."), INVALID_USERNAME(HttpStatus.BAD_REQUEST, "이름은 한글 2자 이상만 입력 가능합니다."), INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "비밀번호는 영어 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다."), + PASSWORD_TOO_SHORT(HttpStatus.BAD_REQUEST,"비밀번호는 8자 이상이어야 합니다."), VERIFICATION_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "인증번호가 만료되었습니다."), NOT_OWNER_CHANGE(HttpStatus.BAD_REQUEST, "오너만 이름을 변경할 수 있습니다."), //오너만 이름 변경!! NOT_OWNER_DELETE(HttpStatus.BAD_REQUEST, "오너만 삭제할 수 있습니다."), //오너만 삭제 가능 @@ -41,6 +42,10 @@ public enum ErrorCode { UNSUPPORTED_REPOSITORY_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 레포지토리 타입입니다."), REPOSITORY_FILES_NOT_FOUND(HttpStatus.BAD_REQUEST, "레포지토리 파일을 찾을 수 없습니다."), FILE_TREE_CONVERSION_FAILED(HttpStatus.BAD_REQUEST, "파일 트리 변환에 실패했습니다."), + FILE_UPLOAD_FAIL(HttpStatus.BAD_REQUEST, "파일 업로드에 실패했습니다."), + PARENT_ID_REQUIRED(HttpStatus.BAD_REQUEST, "부모 폴더 ID는 필수입니다."), + FILE_EXTENSION_REQUIRED(HttpStatus.BAD_REQUEST, "파일 확장자는 필수입니다."), + EMPTY_CHAT_MESSAGE(HttpStatus.BAD_REQUEST, "메시지는 공백일 수 없습니다."), // 401 UNAUTHORIZED UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않았습니다."), @@ -48,6 +53,9 @@ public enum ErrorCode { INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), MISSING_TOKEN(HttpStatus.UNAUTHORIZED, "Access Token이 누락되었습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "이메일 인증이 완료되지 않았습니다."), + EMAIL_NOT_VERIFIED_CODE_RESENT(HttpStatus.UNAUTHORIZED, "인증 코드가 만료되어 새로운 인증 이메일을 발송했습니다."), + // 403 FORBIDDEN FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), diff --git a/src/main/java/com/deepdirect/deepwebide_be/member/domain/EmailVerification.java b/src/main/java/com/deepdirect/deepwebide_be/member/domain/EmailVerification.java index 97985973..a60c61c6 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/member/domain/EmailVerification.java +++ b/src/main/java/com/deepdirect/deepwebide_be/member/domain/EmailVerification.java @@ -21,7 +21,7 @@ public class EmailVerification { @Column(nullable = false) private String emailCode; - @Column(nullable = false, unique = true) + @Column(nullable = false) private String email; @Column(nullable = false) diff --git a/src/main/java/com/deepdirect/deepwebide_be/member/repository/EmailVerificationRepository.java b/src/main/java/com/deepdirect/deepwebide_be/member/repository/EmailVerificationRepository.java index 263ed761..2ddfeef4 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/member/repository/EmailVerificationRepository.java +++ b/src/main/java/com/deepdirect/deepwebide_be/member/repository/EmailVerificationRepository.java @@ -1,6 +1,7 @@ package com.deepdirect.deepwebide_be.member.repository; import com.deepdirect.deepwebide_be.member.domain.EmailVerification; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -10,4 +11,6 @@ public interface EmailVerificationRepository extends JpaRepository findByEmailCode(String code); + + Optional findTopByEmailOrderByCreatedAtDesc(String email); } diff --git a/src/main/java/com/deepdirect/deepwebide_be/member/service/EmailVerificationService.java b/src/main/java/com/deepdirect/deepwebide_be/member/service/EmailVerificationService.java index 84ed3791..95e6031b 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/member/service/EmailVerificationService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/member/service/EmailVerificationService.java @@ -1,5 +1,7 @@ package com.deepdirect.deepwebide_be.member.service; +import com.deepdirect.deepwebide_be.global.exception.ErrorCode; +import com.deepdirect.deepwebide_be.global.exception.GlobalException; import com.deepdirect.deepwebide_be.member.domain.EmailVerification; import com.deepdirect.deepwebide_be.member.repository.EmailVerificationRepository; import lombok.RequiredArgsConstructor; @@ -7,8 +9,10 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Optional; import java.util.UUID; @Service @@ -19,6 +23,7 @@ public class EmailVerificationService { private final EmailVerificationRepository emailVerificationRepository; // 인증 코드 생성 및 저장 + @Transactional public String createVerification(String email) { // 코드 생성 String code = UUID.randomUUID().toString(); @@ -69,4 +74,24 @@ public String findVerifiedEmailByCode(String code) { .map(EmailVerification::getEmail) .orElseThrow(() -> new IllegalArgumentException("해당 코드로 등록된 이메일이 없습니다.")); } + + @Transactional + public void handleEmailVerification(String email) { + Optional latestVerification = emailVerificationRepository.findTopByEmailOrderByCreatedAtDesc(email); + + if (latestVerification.isPresent()) { + EmailVerification verification = latestVerification.get(); + + // 인증 코드가 아직 유효한 경우 + if (verification.getExpiresAt().isAfter(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.EMAIL_NOT_VERIFIED); + } + } + + // 새로운 인증 코드 발송 + String newCode = createVerification(email); + sendVerificationEmail(email, newCode); + + throw new GlobalException(ErrorCode.EMAIL_NOT_VERIFIED_CODE_RESENT); + } } diff --git a/src/main/java/com/deepdirect/deepwebide_be/member/service/UserService.java b/src/main/java/com/deepdirect/deepwebide_be/member/service/UserService.java index cbf14c42..c58f5466 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/member/service/UserService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/member/service/UserService.java @@ -75,6 +75,10 @@ public SignUpResponse signup(SignUpRequest request) { throw new GlobalException(ErrorCode.PASSWORDS_DO_NOT_MATCH); } + if (request.getPassword().length() < 8) { + throw new GlobalException(ErrorCode.PASSWORD_TOO_SHORT); + } + if (!PASSWORD_REGEX.matcher(request.getPassword()).matches()) { throw new GlobalException(ErrorCode.INVALID_PASSWORD_FORMAT); } @@ -117,6 +121,10 @@ public SignInResponse signIn(SignInRequest request, HttpServletResponse servletR User user = userRepository.findByEmail(request.getEmail()) .orElseThrow(() -> new GlobalException(ErrorCode.WRONG_PASSWORD)); + if (!user.isEmailVerified()) { + emailVerificationService.handleEmailVerification(user.getEmail()); + } + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new GlobalException(ErrorCode.WRONG_PASSWORD); }