Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -11,6 +11,8 @@

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

Expand All @@ -21,8 +23,7 @@ public class ImageController {
@Value("${file.upload-dir}")
private String uploadDir;

// consumes 제거
// Swagger용 어노테이션 추가 (파일 선택 버튼 표시용)
// 단일 이미지 업로드
@PostMapping
@Operation(
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
Expand All @@ -33,33 +34,44 @@ public class ImageController {
public ResponseEntity<Map<String, String>> uploadImage(
@RequestParam("file") MultipartFile file
) throws IOException {
String savedUrl = saveFile(file);
return ResponseEntity.ok(Map.of("imageUrl", savedUrl));
}

// 절대 경로로 변환
File dir = new File(uploadDir).getAbsoluteFile();
if (!dir.exists()) {
dir.mkdirs();
// 다중 이미지 업로드 (최대 5장)
// POST /api/images/multi
@PostMapping("/multi")
@Operation(
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
schema = @Schema(type = "object", requiredProperties = {"files"}))
)
)
public ResponseEntity<Map<String, List<String>>> uploadImages(
@RequestParam("files") List<MultipartFile> files
) throws IOException {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().build();
}

// 파일명 중복 방지: UUID + 원본 확장자
String originalName = file.getOriginalFilename();
String extension = "";
if (originalName != null && originalName.contains(".")) {
extension = originalName.substring(originalName.lastIndexOf("."));
// 최대 5장 제한 (서버 부하 방지)
if (files.size() > 5) {
return ResponseEntity.badRequest().build();
}
String savedName = UUID.randomUUID() + extension;

// 절대 경로로 파일 저장
File targetFile = new File(dir, savedName);
file.transferTo(targetFile);

return ResponseEntity.ok(Map.of("imageUrl", "/api/images/" + savedName));
List<String> imageUrls = new ArrayList<>();
for (MultipartFile file : files) {
if (!file.isEmpty()) {
imageUrls.add(saveFile(file));
}
}
return ResponseEntity.ok(Map.of("imageUrls", imageUrls));
}

// 이미지 조회
// GET /api/images/{filename}
@GetMapping("/{filename}")
public ResponseEntity<byte[]> getImage(@PathVariable String filename) throws IOException {
File file = new File(new File(uploadDir).getAbsoluteFile(), filename); // ← 절대 경로
File file = new File(new File(uploadDir).getAbsoluteFile(), filename);

if (!file.exists()) {
return ResponseEntity.notFound().build();
Expand All @@ -71,4 +83,24 @@ public ResponseEntity<byte[]> getImage(@PathVariable String filename) throws IOE
.header("Content-Type", contentType)
.body(java.nio.file.Files.readAllBytes(file.toPath()));
}

// 파일 저장 공통 로직
private String saveFile(MultipartFile file) throws IOException {
File dir = new File(uploadDir).getAbsoluteFile();
if (!dir.exists()) {
dir.mkdirs();
}

String originalName = file.getOriginalFilename();
String extension = "";
if (originalName != null && originalName.contains(".")) {
extension = originalName.substring(originalName.lastIndexOf("."));
}
String savedName = UUID.randomUUID() + extension;

File targetFile = new File(dir, savedName);
file.transferTo(targetFile);

return "/api/images/" + savedName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

public class QuestionReqDTO {

// 질문 등록 요청
@Getter
@NoArgsConstructor
public static class CreateReq {
private String content;
private String imageUrl;
// 이미지 여러 장 지원: URL 목록으로 수신
// 프론트에서 /api/images/multi로 업로드 후 반환된 URL 목록을 넘겨줌
private List<String> imageUrls;
}

// 질문 수정 요청
Expand All @@ -27,7 +31,8 @@ public static class UpdateReq {
@NoArgsConstructor
public static class CommentReq {
private String content;
private String imageUrl;
// 이미지 여러 장 지원
private List<String> imageUrls;
private Long parentCommentId; // 대댓글일 때만 값이 있음, 일반 댓글이면 null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public record QuestionDetailResponse(
Long questionId,
String displayName,
String content,
String imageUrl,
// 이미지 여러 장 지원: URL 목록
List<String> imageUrls,
Boolean isResolved,
Boolean isPopular,
Integer likeCount,
Expand All @@ -91,7 +92,8 @@ public record CommentResponse(
Long commentId,
String displayName,
String content,
String imageUrl,
// 이미지 여러 장 지원
List<String> imageUrls,
Boolean isMine,
LocalDateTime createdAt,
List<CommentResponse> replies
Expand Down Expand Up @@ -161,7 +163,8 @@ public record QuestionGroupsResponse(
public record QuestionSummaryResponse(
Long questionId,
String content,
String imageUrl,
// 이미지 여러 장 지원
List<String> imageUrls,
Boolean isResolved,
Boolean isPopular,
Boolean isLiked,
Expand Down Expand Up @@ -245,7 +248,8 @@ public record QuestionCreatedEvent(
Long sessionId,
Long questionId,
String content,
String imageUrl,
// 이미지 여러 장 지원
List<String> imageUrls,
// 좋아요 수 (생성 직후에는 0)
Integer likeCount,
// 댓글 수 (생성 직후에는 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import lombok.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Table(name = "question")
Expand All @@ -30,6 +34,12 @@ public class Question {
@Column(columnDefinition = "TEXT")
private String content;

/*
이미지 URL 목록을 JSON 배열 문자열로 저장
예시: ["\/api\/images\/uuid1.png","\/api\/images\/uuid2.jpg"]
기존 단일 URL(하위 호환): 기존 데이터에 imageUrl이 JSON 배열이 아닌 단일 URL 문자열로 저장된 경우
getImageUrls()에서 정상적으로 파싱하여 1개짜리 리스트로 반환
*/
@Column(name = "image_url", columnDefinition = "TEXT")
private String imageUrl;

Expand All @@ -48,6 +58,18 @@ public class Question {
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

// 이미지 URL 목록 조회 (JSON 배열 → List<String> 변환)
@Transient
public List<String> getImageUrls() {
return parseImageUrls(this.imageUrl);
}

// 이미지 URL 목록 저장 (List<String> → JSON 배열 문자열 변환)
public void setImageUrls(List<String> imageUrls) {
this.imageUrl = serializeImageUrls(imageUrls);
this.updatedAt = LocalDateTime.now();
}

// 댓글이 새로 달리면 미해결로 되돌리도록
public void markUnresolved() {
this.isResolved = false;
Expand Down Expand Up @@ -85,4 +107,37 @@ public void markResolved() {
this.isResolved = true;
this.updatedAt = LocalDateTime.now();
}

// JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환)
public static List<String> parseImageUrls(String raw) {
if (raw == null || raw.isBlank()) {
return new ArrayList<>();
}
String trimmed = raw.trim();
// JSON 배열 형태인 경우
if (trimmed.startsWith("[")) {
// 간단한 JSON 배열 파싱 (외부 라이브러리 없이)
String inner = trimmed.substring(1, trimmed.length() - 1).trim();
if (inner.isEmpty()) return new ArrayList<>();
return Arrays.stream(inner.split(","))
.map(s -> s.trim().replaceAll("^\"|\"$", ""))
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
// 기존 단일 URL (하위 호환)
List<String> list = new ArrayList<>();
list.add(trimmed);
return list;
}

// List<String> → JSON 배열 문자열 직렬화 유틸
public static String serializeImageUrls(List<String> urls) {
if (urls == null || urls.isEmpty()) {
return null;
}
String joined = urls.stream()
.map(url -> "\"" + url.replace("\"", "\\\"") + "\"")
.collect(Collectors.joining(","));
return "[" + joined + "]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.*;

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

@Entity
@Table(name = "question_comment")
Expand Down Expand Up @@ -39,6 +40,10 @@ public class QuestionComment {
@Column(columnDefinition = "TEXT")
private String content;

/*
이미지 URL 목록을 JSON 배열 문자열로 저장
Question 엔티티와 동일한 방식 사용
*/
@Column(name = "image_url", columnDefinition = "TEXT")
private String imageUrl;

Expand All @@ -51,6 +56,12 @@ public class QuestionComment {
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

// 이미지 URL 목록 조회
@Transient
public List<String> getImageUrls() {
return Question.parseImageUrls(this.imageUrl);
}

// 댓글 내용 수정
public void updateContent(String content) {
this.content = content;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question
.toList();

return new QuestionResDTO.QuestionDetailResponse(
question.getId(), "작성자", question.getContent(), question.getImageUrl(),
question.getId(), "작성자", question.getContent(), question.getImageUrls(),
question.getIsResolved(), isPopular, question.getLikeCount(), isLiked,
isMine,
question.getCreatedAt(), commentResponses
Expand All @@ -106,14 +106,14 @@ private QuestionResDTO.CommentResponse toCommentResponse(Question question, Ques
List<QuestionResDTO.CommentResponse> replyResponses = replies.stream()
.map(reply -> new QuestionResDTO.CommentResponse(
reply.getId(), getDisplayName(question, reply.getUser()),
reply.getContent(), reply.getImageUrl(), isCommentMine(reply, loginUser),
reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser),
reply.getCreatedAt(), List.of()
))
.toList();

return new QuestionResDTO.CommentResponse(
comment.getId(), getDisplayName(question, comment.getUser()),
comment.getContent(), comment.getImageUrl(), isCommentMine(comment, loginUser),
comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser),
comment.getCreatedAt(), replyResponses
);
}
Expand All @@ -137,7 +137,7 @@ public QuestionResDTO.CommentCreateRes createComment(
QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question);

// builder 전에 검증 추가
validateCommentContent(request.getContent(), request.getImageUrl());
validateCommentContent(request.getContent(), request.getImageUrls());

// 2. 댓글 엔티티 생성 및 저장
LocalDateTime now = LocalDateTime.now();
Expand All @@ -146,7 +146,7 @@ public QuestionResDTO.CommentCreateRes createComment(
.user(loginUser)
.parentComment(parentComment) // 일반 댓글이면 null, 대댓글이면 부모 댓글
.content(request.getContent())
.imageUrl(request.getImageUrl())
.imageUrl(Question.serializeImageUrls(request.getImageUrls()))
.createdAt(now)
.updatedAt(now)
.build();
Expand Down Expand Up @@ -252,13 +252,13 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr
StudySession session = findSession(sessionId);

// builder 전에 검증 추가
validateQuestionContent(request.getContent(), request.getImageUrl());
validateQuestionContent(request.getContent(), request.getImageUrls());

Question question = Question.builder()
.session(session)
.user(loginUser)
.content(request.getContent())
.imageUrl(request.getImageUrl())
.imageUrl(Question.serializeImageUrls(request.getImageUrls()))
.isResolved(false)
.likeCount(0)
.createdAt(LocalDateTime.now())
Expand Down Expand Up @@ -654,7 +654,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse (
boolean isLiked = summaryContext.likedQuestionIds().contains(questionId);
boolean isMine = question.getUser().getId().equals(loginUser.getId());
return new QuestionResDTO.QuestionSummaryResponse(
questionId, question.getContent(), question.getImageUrl(),
questionId, question.getContent(), question.getImageUrls(),
question.getIsResolved(),
!question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD,
isLiked,
Expand Down Expand Up @@ -713,6 +713,7 @@ private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse(
}

private boolean hasPreviewImage(QuestionCommentRepository.PreviewCommentRow row) {
// image_url 컬럼에 값이 있으면 이미지 있는 것으로 처리 (JSON 배열 또는 단일 URL 모두 포함)
return row.getImageUrl() != null && !row.getImageUrl().isBlank();
}

Expand Down Expand Up @@ -789,7 +790,7 @@ private void publishQuestionCreatedEventAfterCommit(Question question) {
sessionId,
question.getId(),
question.getContent(),
question.getImageUrl(),
question.getImageUrls(),
question.getLikeCount(),
0, // 방금 만들어진 질문이므로 댓글 수는 0
question.getCreatedAt()
Expand Down Expand Up @@ -871,18 +872,18 @@ private record QuestionSummaryContext(
}

// 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함
private void validateQuestionContent(String content, String imageUrl) {
private void validateQuestionContent(String content, List<String> imageUrls) {
boolean hasContent = content != null && !content.isBlank();
boolean hasImage = imageUrl != null && !imageUrl.isBlank();
boolean hasImage = imageUrls != null && !imageUrls.isEmpty();
if (!hasContent && !hasImage) {
throw new QuestionException(HttpStatus.BAD_REQUEST, "질문 내용 또는 이미지 중 하나는 필수입니다.");
}
}

// 댓글은 내용 또는 이미지 중 하나는 반드시 있어야 함
private void validateCommentContent(String content, String imageUrl) {
private void validateCommentContent(String content, List<String> imageUrls) {
boolean hasContent = content != null && !content.isBlank();
boolean hasImage = imageUrl != null && !imageUrl.isBlank();
boolean hasImage = imageUrls != null && !imageUrls.isEmpty();
if (!hasContent && !hasImage) {
throw new QuestionException(HttpStatus.BAD_REQUEST, "댓글 내용 또는 이미지 중 하나는 필수입니다.");
}
Expand Down
Loading
Loading