Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c9f6625
fix(eooro): 이메일 인증이 되지 않은 상태임을 알려주는 에러 코드 추가 | DP-171
Shin-Yu-1 Jul 31, 2025
243ab33
fix(service): 이메일 인증이 되지 않은 경우 에러 반환 | DP-171
Shin-Yu-1 Jul 31, 2025
8dc8dda
fix(비밀번호 글자수 에러 메시지 추가): 비밀번호 글자수 에러 메시지 추가 DP-175
vayaconChoi Jul 31, 2025
ed7a934
feature(chat): chatMessage ManyToOne -> OneToOne 관계 변경 DP-176
sunsetkk Jul 31, 2025
26d8d5b
fix(service): 로그인 시 이메일 인즈 안 된 경우 핸들러 동작 | DP-171
Shin-Yu-1 Jul 31, 2025
be0b052
fix(repository): 가장 최근 발송된 이메일 인증 정보 가져오기 | DP-171
Shin-Yu-1 Jul 31, 2025
35f448e
fix(service): 인증 코드 발송 및 에러 반환 | DP-171
Shin-Yu-1 Jul 31, 2025
834b6f0
feat(ErrorCode): FILE_UPLOAD_FAIL error code 추가 DP-177
projectmiluju Jul 31, 2025
a2a1cff
feature(chat): references -> reference 단일 참조 구조 변경 및 처리 DP-176
sunsetkk Jul 31, 2025
f5f5d9c
feat(FileUpload): 파일 업로드 기능 구현 DP-177
projectmiluju Jul 31, 2025
9601f05
fix(domain): 이메일 유니크 제거 | DP-171
Shin-Yu-1 Jul 31, 2025
ad789dd
feat(ErrorCode): PARENT_ID_REQUIRED error code 추가 DP-177
projectmiluju Jul 31, 2025
8063e11
feat(Validation): 부모 폴더 ID와 이동할 폴더 ID 필수 검증 추가 DP-177
projectmiluju Jul 31, 2025
4382dce
feat(Validation): parentId를 필수 값으로 수정 DP-177
projectmiluju Jul 31, 2025
7ef4ddb
feat(ErrorCode): FILE_EXTENSION_REQUIRED error code 추가 DP-177
projectmiluju Jul 31, 2025
4bb45d3
feat(Validation): 파일 이름에 확장자 검증 추가 DP-177
projectmiluju Jul 31, 2025
a371f31
feat(FileService): 파일 확장자에 따라 내용 변환 방식 추가 DP-177
projectmiluju Jul 31, 2025
b005c80
chore(chat): 채팅 메세지 저장 관련 예외 메세지 추가 DP-178
sunsetkk Jul 31, 2025
abf9c59
fix(chat): 공백 저장 방지 로직 추가 DP-178
sunsetkk Jul 31, 2025
e89bd03
Merge pull request #184 from DeepDirect/feature/DP-173/email-auth
Shin-Yu-1 Jul 31, 2025
bf0e420
feat(FileService): 30초마다 저장으로 변경 DP-177
projectmiluju Aug 1, 2025
df740c1
Merge pull request #180 from DeepDirect/fix/DP-175-passwordeight
projectmiluju Aug 1, 2025
585b40f
Merge pull request #183 from DeepDirect/feature/DP-176-chat
projectmiluju Aug 1, 2025
816a81c
Merge pull request #186 from DeepDirect/fix/DP-178-chat
projectmiluju Aug 1, 2025
46f2232
Merge branch 'develop' into feature/DP-177-file
projectmiluju Aug 1, 2025
0a30206
Merge pull request #187 from DeepDirect/feature/DP-177-file
projectmiluju Aug 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,6 +13,7 @@
@Schema(description = "채팅 메시지 전송 요청")
public class ChatMessageRequest {

@NotBlank(message = "메시지는 공백일 수 없습니다.")
@Schema(description = "메시지 내용", example = "코드 이부분 이상한 것 같아")
private String message;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.List;

@Getter
@Builder
Expand All @@ -27,8 +26,8 @@ public class ChatMessageResponse {
@Schema(description = "메시지 본문")
private final String message;

@Schema(description = "코드 참조 목록")
private final List<CodeReferenceResponse> codeReferences;
@Schema(description = "코드 참조")
private final CodeReferenceResponse codeReference;

@Schema(description = "내 메시지 여부", name = "IsMine")
private final boolean isMine;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,10 +77,14 @@ public ChatMessagesResponse getMessages(Long repositoryId, Long userId, Long bef

// 참조 메시지 조회
List<Long> messageIds = messages.stream().map(ChatMessage::getId).toList();
Map<Long, List<ChatMessageReference>> referenceMap = referenceRepository
Map<Long, ChatMessageReference> referenceMap = referenceRepository
.findByChatMessageIdIn(messageIds)
.stream()
.collect(Collectors.groupingBy(ref -> ref.getChatMessage().getId()));
.collect(Collectors.toMap(
ref -> ref.getChatMessage().getId(),
Function.identity()
));


// 응답 변환
List<ChatMessageResponse> responses = messages.stream()
Expand All @@ -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())
Expand Down Expand Up @@ -121,9 +125,11 @@ public ChatMessageSearchResponse searchMessages(Long repositoryId, Long userId,

long total = chatMessageRepository.countByRepositoryIdAndMessageContainingIgnoreCase(repositoryId, keyword);

Map<Long, List<ChatMessageReference>> ref = referenceRepository
Map<Long, ChatMessageReference> 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<ChatMessageResponse> responses = result.stream().map(msg ->
ChatMessageResponse.builder()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -111,4 +113,15 @@ public ResponseEntity<ApiResponseDto<FileContentSaveResponse>> saveFileContent(
);
return ResponseEntity.ok(ApiResponseDto.of(200, "파일 내용 저장 완료했습니다.", response));
}

@PostMapping("/{repositoryId}/files/upload")
public ResponseEntity<ApiResponseDto<FileNodeResponse>> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> dirtyFileKeys = redisTemplate.opsForSet().members(DIRTY_SET_KEY);
if (dirtyFileKeys == null) return;
Expand Down
129 changes: 114 additions & 15 deletions src/main/java/com/deepdirect/deepwebide_be/file/service/FileService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +65,15 @@ public List<FileTreeNodeResponse> 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));
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -190,24 +205,27 @@ 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);

// 새 부모 폴더 체크
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(
Expand All @@ -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();
}
Expand All @@ -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())
Expand Down Expand Up @@ -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);
}
}

}
Loading