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 @@ -26,13 +26,13 @@ public class BinaryContent extends BaseUpdatableEntity {
@Column(name = "upload_status", nullable = false, length = 20)
private BinaryContentUploadStatus uploadStatus;

public static BinaryContent pending(String url) {
BinaryContent binaryContent = new BinaryContent();
public static BinaryContent completed(String url) {
if (!StringUtils.hasText(url)) {
throw new InvalidBinaryContentUrlException();
}
BinaryContent binaryContent = new BinaryContent();
binaryContent.url = url;
binaryContent.uploadStatus = BinaryContentUploadStatus.PENDING;
binaryContent.uploadStatus = BinaryContentUploadStatus.COMPLETED;
return binaryContent;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.codeit.team5.mopl.binarycontent.entity;

public enum BinaryContentUploadStatus {
PENDING,
COMPLETED,
FAILED,
DELETED
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BinaryContentService {

private final BinaryContentStorage binaryContentStorage;
private final StorageKeyFactory storageKeyFactory;
private final BinaryContentRepository binaryContentRepository;

@Transactional
public void updateUploadStatus(UUID binaryContentId, BinaryContentUploadStatus status) {
binaryContentRepository.findById(binaryContentId)
.orElseThrow(() -> new BinaryContentNotFoundException(binaryContentId))
.updateUploadStatus(status);
public BinaryContent upload(StorageDirectory directory, UUID ownerId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(directory, ownerId, image.filename());
binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType());
return binaryContentRepository.save(
BinaryContent.completed(binaryContentStorage.toUrl(generated.key())));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.codeit.team5.mopl.content.dto.response;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.content.entity.ContentType;
import java.util.List;
import java.util.UUID;
Expand All @@ -11,7 +10,6 @@ public record ContentResponse(
String title,
String description,
String thumbnailUrl,
BinaryContentUploadStatus thumbnailUploadStatus,
List<String> tags,
double averageRating,
int reviewCount,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codeit.team5.mopl.content.entity;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.content.exception.InvalidContentSourceException;
import com.codeit.team5.mopl.content.exception.InvalidContentTitleException;
import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
Expand Down Expand Up @@ -106,6 +107,9 @@ public static Content createByExternalSource(ContentType type, String title, Str
}

public void attachThumbnail(BinaryContent thumbnail) {
if (this.thumbnail != null) {
this.thumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED);
}
this.thumbnail = thumbnail;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public interface ContentMapper {
@Mapping(target = "reviewCount", source = "stats.reviewCount")
@Mapping(target = "watcherCount", source = "stats.watcherCount")
@Mapping(target = "thumbnailUrl", source = "thumbnail.url")
@Mapping(target = "thumbnailUploadStatus", source = "thumbnail.uploadStatus")
ContentResponse toDto(Content content);

default CursorResponse<ContentResponse> toCursor(List<Content> page, boolean hasNext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@

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.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
Expand All @@ -34,7 +30,6 @@
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -54,10 +49,7 @@ public class ContentService {
private final ContentStatsRepository contentStatsRepository;
private final TagRepository tagRepository;
private final ContentMapper contentMapper;
private final BinaryContentStorage binaryContentStorage;
private final StorageKeyFactory storageKeyFactory;
private final BinaryContentRepository binaryContentRepository;
private final ApplicationEventPublisher eventPublisher;
private final BinaryContentService binaryContentService;

private static final String SECONDARY_SORT_FIELD = "id";

Expand All @@ -76,15 +68,15 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) {
request.description()
));

if (image != null) {
content.attachThumbnail(storeThumbnailImage(content.getId(), image));
}

attachTags(content, request.tags());

ContentStats stats = contentStatsRepository.save(ContentStats.create(content));
content.attachStats(stats);

if (image != null) {
content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return contentMapper.toDto(content);
}

Expand All @@ -108,12 +100,7 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File
updateTags(content, tagNames);

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

return contentMapper.toDto(content);
Expand Down Expand Up @@ -164,18 +151,6 @@ public void delete(UUID contentId) {
contentRepository.delete(content);
}

private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename());
BinaryContent thumbnailImage = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(generated.key())));

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

return thumbnailImage;
}

private void attachTags(Content content, List<String> rawTagNames) {
List<String> tagNames = normalizeTagNames(rawTagNames);
insertTags(content, tagNames);
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/codeit/team5/mopl/user/entity/User.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codeit.team5.mopl.user.entity;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
import com.codeit.team5.mopl.user.exception.InvalidPasswordException;
import com.codeit.team5.mopl.user.exception.InvalidUsernameException;
Expand Down Expand Up @@ -68,6 +69,9 @@ public void updateName(String newName) {
}

public void updateProfileImage(BinaryContent profileImage) {
if (this.profileImage != null) {
this.profileImage.updateUploadStatus(BinaryContentUploadStatus.DELETED);
}
this.profileImage = profileImage;
Comment thread
dstle marked this conversation as resolved.
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -20,7 +19,7 @@ public interface UserRepository extends JpaRepository<User, UUID> {
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") UUID id);

// N+1 문제 방지 update용 조회 메서드
@EntityGraph(attributePaths = {"profileImage"})
Optional<User> findWithProfileImageById(UUID id);
// 프로필 이미지를 fetch join으로 함께 조회 (update 시 N+1 방지)
@Query("select u from User u left join fetch u.profileImage where u.id = :id")
Optional<User> findWithProfileImageById(@Param("id") UUID id);
}
45 changes: 12 additions & 33 deletions src/main/java/com/codeit/team5/mopl/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
package com.codeit.team5.mopl.user.service;

import com.codeit.team5.mopl.auth.service.RefreshTokenStore;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
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.event.BinaryContentUploadEvent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.global.dto.FileRequest;
import com.codeit.team5.mopl.notification.event.RoleChangedEvent;
import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest;
Expand Down Expand Up @@ -41,9 +35,7 @@ public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final BinaryContentStorage binaryContentStorage;
private final StorageKeyFactory storageKeyFactory;
private final BinaryContentRepository binaryContentRepository;
private final BinaryContentService binaryContentService;
private final ApplicationEventPublisher eventPublisher;
private final RefreshTokenStore refreshTokenStore;

Expand Down Expand Up @@ -79,20 +71,11 @@ public UserResponse getById(UUID userId) {
public UserResponse update(UUID currentUserId, UUID userId, UserUpdateRequest request, FileRequest image) {
validateOwner(currentUserId, userId);

User user = userRepository.findWithProfileImageById(userId)
.orElseThrow(() -> {
log.warn("User not found: userId={}", userId);
return new UserNotFoundException(userId);
});
User user = getUserWithProfileImage(userId);

user.updateName(request.name());
if (image != null) {
// TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지)
BinaryContent oldProfile = user.getProfileImage();
if (oldProfile != null) {
oldProfile.updateUploadStatus(BinaryContentUploadStatus.DELETED);
}
user.updateProfileImage(storeProfileImage(user.getId(), image));
user.updateProfileImage(binaryContentService.upload(StorageDirectory.PROFILE, user.getId(), image));
Comment thread
dstle marked this conversation as resolved.
}

log.info("User updated: userId={}", userId);
Expand Down Expand Up @@ -133,24 +116,20 @@ public void updateLock(UUID userId, UserLockedUpdateRequest request) {
log.info("User lock status updated: userId={}, isLocked={}", userId, request.locked());
}

private BinaryContent storeProfileImage(UUID userId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, userId, image.filename());
BinaryContent profileImage = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(generated.key())));

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

return profileImage;
}

private void validateOwner(UUID currentUserId, UUID userId) {
if (!currentUserId.equals(userId)) {
throw new UserForbiddenException(currentUserId, userId);
}
}

private User getUserWithProfileImage(UUID userId) {
return userRepository.findWithProfileImageById(userId)
.orElseThrow(() -> {
log.warn("User not found: userId={}", userId);
return new UserNotFoundException(userId);
});
}

private User getUser(UUID userId) {
return userRepository.findById(userId)
.orElseThrow(() -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
UPDATE binary_contents
SET upload_status = 'COMPLETED'
WHERE upload_status IN ('PENDING', 'FAILED');

ALTER TABLE binary_contents
DROP CONSTRAINT ck_binary_contents_upload_status;

ALTER TABLE binary_contents
ADD CONSTRAINT ck_binary_contents_upload_status
CHECK (upload_status IN ('COMPLETED', 'DELETED'));
Comment thread
dstle marked this conversation as resolved.
Loading
Loading