Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7b1fc65
refactor: EntityGraph attributePaths 수정
plzslp Jun 25, 2026
920ee39
feat: ContentController 수정 API 엔드포인트 추가
plzslp Jun 25, 2026
172cd16
feat: Content 수정 비지니스 로직 작성
plzslp Jun 25, 2026
f8bafbc
feat: Content 수정 필드 검증 실패 예외 추가
plzslp Jun 25, 2026
b5da4f3
refactor: Content 설명은 nullable 유지
plzslp Jun 25, 2026
47596ce
refactor: Content 수정 로직에 썸네일 수정 로직 추가
plzslp Jun 25, 2026
18527bb
refactor: 썸네일 수정 시에 기존 썸네일 상태를 PENDING으로 변경하는 로직 추가
plzslp Jun 25, 2026
d4b9ca2
feat: ContentController 삭제 API 엔드포인트 추가
plzslp Jun 25, 2026
76f37fb
feat: Content 삭제 비지니스 로직 작성
plzslp Jun 25, 2026
9a940c3
refactor: BinaryContentUploadStatus에 DELETED 상태 추가
plzslp Jun 25, 2026
af83fd3
refactor: 최대 태그 개수 제한 비지니스 규칙 추가 및 코드 적용
plzslp Jun 25, 2026
0c99824
refactor: ContentApi ErrorResponse 마이그레이션
plzslp Jun 25, 2026
2e5fc95
test: ContentRepository 테스트 코드 작성
plzslp Jun 25, 2026
3d43379
refactor: ContentNotFoundException details 추가
plzslp Jun 26, 2026
daba827
refactor: 파일 업로드 로직 변경사항 ContentController/Service 적용
plzslp Jun 26, 2026
1dc7b66
refactor: User 프로필 업데이트시 기존 프로필 Soft Delete 로직 추가
plzslp Jun 26, 2026
18f55bb
refactor: V10 schema.sql 중복으로 인해 V11로 변경
plzslp Jun 26, 2026
b44bd1a
refactor: 사용자 프로필 테스트 findWithProfileImageById() 적용
plzslp Jun 26, 2026
2fafd8c
refactor: ContentException 추상클래스화 및 생성자 추가
plzslp Jun 26, 2026
baaee05
test: ContentController 수정, 삭제 API 테스트 코드 작성
plzslp Jun 26, 2026
ae254d3
test: ContentService 수정, 삭제 로직 테스트 코드 작성
plzslp Jun 26, 2026
3ff1776
refactor: ContentService 태그 갱신 로직 diff방식으로 변경
plzslp Jun 26, 2026
c67621f
refactor: 커서 페이지네이션 조회 메서드 toMany EntityGraph 제거
plzslp Jun 26, 2026
466307d
refactor: 콘텐츠 수정 로직 중 태그 검증, 부분 변경 호출 위치 변경
plzslp Jun 26, 2026
3dc5faf
refactor: 콘텐츠 엔티티 update() StringUtils 적용
plzslp Jun 26, 2026
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 @@ -3,5 +3,6 @@
public enum BinaryContentUploadStatus {
PENDING,
COMPLETED,
FAILED
FAILED,
DELETED
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@

import com.codeit.team5.mopl.content.controller.api.ContentApi;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
Expand All @@ -35,4 +40,24 @@ public ResponseEntity<ContentResponse> postContent(
ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@Override
@PatchMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ContentResponse> patchContent(
@PathVariable UUID contentId,
@Valid @RequestPart("request") ContentUpdateRequest request,
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Update request: PATCH /api/contents/{}", contentId);
ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.ok(response);
}

@Override
@DeleteMapping("/{contentId}")
public ResponseEntity<Void> deleteContent(@PathVariable UUID contentId) {
log.info("Content Delete request: DELETE /api/contents/{}", contentId);
contentService.delete(contentId);
return ResponseEntity.noContent().build();
}
Comment thread
plzslp marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.codeit.team5.mopl.content.controller.api;

import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.global.dto.ErrorResponse;
import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -12,6 +13,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -20,7 +22,7 @@
public interface ContentApi {

@Schema(name = "ContentCreateMultipartRequest")
class MultipartRequest {
class ContentCreateMultipartRequest {
@Schema(implementation = ContentCreateRequest.class)
public ContentCreateRequest request;

Expand All @@ -33,21 +35,72 @@ class MultipartRequest {
@ApiResponse(responseCode = "201", description = "성공",
content = @Content(schema = @Schema(implementation = ContentResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
@RequestBody(content = @Content(
mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
schema = @Schema(implementation = MultipartRequest.class),
schema = @Schema(implementation = ContentCreateMultipartRequest.class),
encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE)
))
ResponseEntity<ContentResponse> postContent(
@Parameter(hidden = true) ContentCreateRequest request,
@Parameter(hidden = true) MultipartFile thumbnail
);

@Schema(name = "ContentUpdateMultipartRequest")
class ContentUpdateMultipartRequest {
@Schema(implementation = ContentUpdateRequest.class)
public ContentCreateRequest request;

@Schema(type = "string", format = "binary", description = "썸네일 이미지")
public MultipartFile thumbnail;
}

@Operation(summary = "[어드민] 콘텐츠 수정", description = "콘텐츠의 제목, 설명, 태그를 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공",
content = @Content(schema = @Schema(implementation = ContentResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "404", description = "콘텐츠 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
@RequestBody(content = @Content(
mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
schema = @Schema(implementation = ContentUpdateMultipartRequest.class),
encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE)
))
ResponseEntity<ContentResponse> patchContent(
@Parameter(description = "콘텐츠 ID") UUID contentId,
@Parameter(hidden = true) ContentUpdateRequest request,
@Parameter(hidden = true) MultipartFile thumbnail
);

@Operation(summary = "[어드민] 콘텐츠 삭제", description = "콘텐츠를 삭제합니다.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "404", description = "콘텐츠 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<Void> deleteContent(@Parameter(description = "콘텐츠 ID") UUID contentId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public record ContentCreateRequest(

@NotNull(message = "콘텐츠 태그는 필수 입니다.")
@NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.")
@Size(max = 10, message = "태그는 최대 10개까지 등록할 수 있습니다.")
List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.codeit.team5.mopl.content.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record ContentUpdateRequest(
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 500, message = "제목은 500자를 초과할 수 없습니다.")
String title,

String description,

@NotNull(message = "콘텐츠 태그는 필수입니다.")
@NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.")
@Size(max = 10, message = "태그는 최대 10개까지 등록할 수 있습니다.")
List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags
) {
}
13 changes: 13 additions & 0 deletions src/main/java/com/codeit/team5/mopl/content/entity/Content.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.content.exception.InvalidContentSourceException;
import com.codeit.team5.mopl.content.exception.InvalidContentTitleException;
import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
Expand Down Expand Up @@ -116,4 +117,16 @@ public void addTag(ContentTag contentTag) {
}
contentTags.add(contentTag);
}

public void clearTags() {
contentTags.clear();
}

public void update(String title, String description) {
if (title == null || title.isBlank()) {
Comment thread
conradrado marked this conversation as resolved.
Outdated
throw new InvalidContentTitleException();
}
this.title = title;
this.description = description;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.codeit.team5.mopl.content.exception;

import com.codeit.team5.mopl.global.exception.BusinessException;
import java.util.Map;
import org.springframework.http.HttpStatus;

public class ContentException extends BusinessException {
public abstract class ContentException extends BusinessException {

public ContentException(HttpStatus status, String message) {
super(status, message);
}

public ContentException(HttpStatus status, String message, Map<String, Object> details) {
super(status, message, details);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.codeit.team5.mopl.content.exception;

import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;

public class ContentNotFoundException extends ContentException {

public ContentNotFoundException() {
super(HttpStatus.NOT_FOUND, "콘텐츠를 찾을 수 없습니다.");
public ContentNotFoundException(UUID contentId) {
super(HttpStatus.NOT_FOUND, "콘텐츠를 찾을 수 없습니다.", Map.of("contentId", contentId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.content.exception;

import org.springframework.http.HttpStatus;

public class InvalidContentTitleException extends ContentException {

public InvalidContentTitleException() {
super(HttpStatus.BAD_REQUEST, "제목은 필수입니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.content.exception;

import org.springframework.http.HttpStatus;

public class TooManyTagsException extends ContentException {

public TooManyTagsException() {
super(HttpStatus.BAD_REQUEST, "태그는 최대 10개까지 등록할 수 있습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@

public interface ContentRepository extends JpaRepository<Content, UUID> {

@EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags"})
@EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"})
Optional<Content> findWithStatsAndTagsById(UUID id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentStats;
import com.codeit.team5.mopl.content.exception.ContentNotFoundException;
import com.codeit.team5.mopl.content.exception.EmptyTagException;
import com.codeit.team5.mopl.content.exception.TooManyTagsException;
import com.codeit.team5.mopl.content.mapper.ContentMapper;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
Expand All @@ -21,6 +25,7 @@
import com.codeit.team5.mopl.tag.repository.TagRepository;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
Expand All @@ -45,20 +50,15 @@ public class ContentService {
private final ApplicationEventPublisher eventPublisher;

@Transactional
public ContentResponse create(ContentCreateRequest request, FileRequest thumbnail) {
public ContentResponse create(ContentCreateRequest request, FileRequest image) {
Content content = contentRepository.save(Content.createByAdmin(
request.type(),
request.title(),
request.description()
));

if (thumbnail != null) {
GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, content.getId(), thumbnail.filename());
BinaryContent binaryContent = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(generated.key())));
content.attachThumbnail(binaryContent);
eventPublisher.publishEvent(new BinaryContentUploadEvent(
binaryContent.getId(), generated.key(), thumbnail.bytes(), generated.contentType()));
if (image != null) {
content.attachThumbnail(storeThumbnailImage(content.getId(), image));
}

attachTags(content, request.tags());
Expand All @@ -69,6 +69,50 @@ public ContentResponse create(ContentCreateRequest request, FileRequest thumbnai
return contentMapper.toDto(content);
}

@Transactional
public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) {
Content content = contentRepository.findWithStatsAndTagsById(contentId)
.orElseThrow(() -> new ContentNotFoundException(contentId));

content.update(request.title(), request.description());
content.clearTags();
attachTags(content, request.tags());
Comment thread
plzslp marked this conversation as resolved.
Outdated

if (image != null) {
// TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지)
BinaryContent oldThumbnail = content.getThumbnail();
if (oldThumbnail != null) {
oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED);
}
content.attachThumbnail(storeThumbnailImage(content.getId(), image));
}

return contentMapper.toDto(content);
}

@Transactional
public void delete(UUID contentId) {
Content content = contentRepository.findWithStatsAndTagsById(contentId)
.orElseThrow(() -> new ContentNotFoundException(contentId));
BinaryContent oldThumbnail = content.getThumbnail();
if (oldThumbnail != null) {
oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED);
}
contentRepository.delete(content);
}

private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename());
BinaryContent profileImage = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(generated.key())));
Comment thread
plzslp marked this conversation as resolved.

eventPublisher.publishEvent(
new BinaryContentUploadEvent(profileImage.getId(), generated.key(), image.bytes(), generated.contentType()));
// TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 썸네일 이미지 롤백

return profileImage;
}

private void attachTags(Content content, List<String> rawTagNames) {
List<String> tagNames = rawTagNames.stream()
.map(String::trim)
Expand All @@ -80,6 +124,9 @@ private void attachTags(Content content, List<String> rawTagNames) {
if (tagNames.isEmpty()) {
throw new EmptyTagException();
}
if (tagNames.size() > 10) {
throw new TooManyTagsException();
}

Map<String, Tag> existingTags = tagRepository.findByNameIn(tagNames).stream()
.collect(Collectors.toMap(Tag::getName, Function.identity()));
Expand Down
Loading
Loading