From 8a93ad7f3d696a1751bacefdc4d41bf0947ea31e Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 6 Aug 2026 00:18:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B5=9C=EA=B7=BC=20=ED=99=9C=EB=8F=99?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ActivityLogRepository.java | 10 ++ .../ProjectLatestActivityProjection.java | 10 ++ .../project/service/ProjectService.java | 28 ++++- .../ActivityLogRepositoryIntegrationTest.java | 112 ++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/slatto/domain/notification/repository/ProjectLatestActivityProjection.java create mode 100644 src/test/java/com/slatto/domain/notification/repository/ActivityLogRepositoryIntegrationTest.java diff --git a/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java b/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java index d1e73e25..18bf4b9c 100644 --- a/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java +++ b/src/main/java/com/slatto/domain/notification/repository/ActivityLogRepository.java @@ -31,4 +31,14 @@ List findRecentActivitiesByCursor( ); Optional findByIdAndProjectId(Long activityId, Long projectId); + + @Query(""" + select al.project.id as projectId, max(al.createdAt) as lastActivityAt + from ActivityLog al + where al.project.id in :projectIds + group by al.project.id + """) + List findLatestActivityAtByProjectIds( + @Param("projectIds") List projectIds + ); } diff --git a/src/main/java/com/slatto/domain/notification/repository/ProjectLatestActivityProjection.java b/src/main/java/com/slatto/domain/notification/repository/ProjectLatestActivityProjection.java new file mode 100644 index 00000000..db4f2c44 --- /dev/null +++ b/src/main/java/com/slatto/domain/notification/repository/ProjectLatestActivityProjection.java @@ -0,0 +1,10 @@ +package com.slatto.domain.notification.repository; + +import java.time.LocalDateTime; + +public interface ProjectLatestActivityProjection { + + Long getProjectId(); + + LocalDateTime getLastActivityAt(); +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectService.java b/src/main/java/com/slatto/domain/project/service/ProjectService.java index e925687e..61bfde7c 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -17,6 +17,8 @@ import com.slatto.domain.project.repository.ProjectPinRepository; import com.slatto.domain.project.repository.ProjectRepository; import com.slatto.domain.project.repository.ProjectUserRoleRepository; +import com.slatto.domain.notification.repository.ActivityLogRepository; +import com.slatto.domain.notification.repository.ProjectLatestActivityProjection; import com.slatto.domain.notification.service.ActivityLogService; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.RoleName; @@ -54,6 +56,7 @@ public class ProjectService { private final ProjectConverter projectConverter; private final ProjectAccessValidator projectAccessValidator; private final ActivityLogService activityLogService; + private final ActivityLogRepository activityLogRepository; @Transactional public ProjectResponse createProject(Long ownerUserId, ProjectCreateRequest request) { @@ -97,6 +100,7 @@ public ProjectListResponse getProjects( Map> roleNamesByMemberId = getRoleNamesByMemberId(currentPageMembers); Map previewImageUrlByProjectId = getPreviewImageUrlByProjectId(currentPageMembers); Map pinnedAtByProjectId = getPinnedAtByProjectId(currentUserId, currentPageMembers); + Map lastActivityAtByProjectId = getLastActivityAtByProjectId(currentPageMembers); Long nextCursor = hasNext && !currentPageMembers.isEmpty() ? currentPageMembers.get(currentPageMembers.size() - 1).getProject().getId() @@ -111,7 +115,7 @@ public ProjectListResponse getProjects( previewImageUrlByProjectId.get(projectMember.getProject().getId()), pinnedAtByProjectId.get(projectMember.getProject().getId()), projectMember.getPermission(), - resolveLastActivityAt(projectMember.getProject()) + lastActivityAtByProjectId.get(projectMember.getProject().getId()) )) .toList(); @@ -311,6 +315,24 @@ private Map getPinnedAtByProjectId(Long userId, List getLastActivityAtByProjectId(List projectMembers) { + List projectIds = projectMembers.stream() + .map(ProjectMember::getProject) + .map(Project::getId) + .toList(); + + if (projectIds.isEmpty()) { + return Map.of(); + } + + return activityLogRepository.findLatestActivityAtByProjectIds(projectIds) + .stream() + .collect(Collectors.toMap( + ProjectLatestActivityProjection::getProjectId, + ProjectLatestActivityProjection::getLastActivityAt + )); + } + private LocalDateTime getProjectCursorPinnedAt(Long userId, Long cursor) { if (cursor == null) { return null; @@ -321,10 +343,6 @@ private LocalDateTime getProjectCursorPinnedAt(Long userId, Long cursor) { .orElse(null); } - private LocalDateTime resolveLastActivityAt(Project project) { - return project.getUpdatedAt() != null ? project.getUpdatedAt() : project.getCreatedAt(); - } - private int normalizePageSize(int size) { if (size <= 0) { return DEFAULT_PAGE_SIZE; diff --git a/src/test/java/com/slatto/domain/notification/repository/ActivityLogRepositoryIntegrationTest.java b/src/test/java/com/slatto/domain/notification/repository/ActivityLogRepositoryIntegrationTest.java new file mode 100644 index 00000000..b46f9606 --- /dev/null +++ b/src/test/java/com/slatto/domain/notification/repository/ActivityLogRepositoryIntegrationTest.java @@ -0,0 +1,112 @@ +package com.slatto.domain.notification.repository; + +import com.slatto.domain.notification.entity.ActivityLog; +import com.slatto.domain.notification.enums.ActivityLogTargetType; +import com.slatto.domain.notification.enums.ActivityLogType; +import com.slatto.domain.notification.model.ActivityActor; +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.project.repository.ProjectRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class ActivityLogRepositoryIntegrationTest { + + @Autowired + private ActivityLogRepository activityLogRepository; + + @Autowired + private ProjectRepository projectRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void 프로젝트별_가장_최근_활동_시각만_조회한다() { + // 홈 프로젝트 카드는 프로젝트 수정 시각이 아니라 실제 활동 로그의 최신 발생 시각을 보여줘야 한다. + Users owner = userRepository.save(Users.createSocialUser( + "chataehun@example.com", "차태훈", null, SocialType.GOOGLE, "google-chataehun" + )); + Project firstProject = saveProject(owner, "첫 번째 프로젝트"); + Project secondProject = saveProject(owner, "두 번째 프로젝트"); + LocalDateTime firstProjectLatest = LocalDateTime.of(2026, 8, 5, 10, 30); + LocalDateTime secondProjectLatest = LocalDateTime.of(2026, 8, 5, 11, 45); + + saveActivity(firstProject, owner, LocalDateTime.of(2026, 8, 5, 9, 0)); + saveActivity(firstProject, owner, firstProjectLatest); + saveActivity(secondProject, owner, secondProjectLatest); + entityManager.flush(); + entityManager.clear(); + + Map lastActivityAtByProjectId = activityLogRepository + .findLatestActivityAtByProjectIds(List.of(firstProject.getId(), secondProject.getId())) + .stream() + .collect(Collectors.toMap( + ProjectLatestActivityProjection::getProjectId, + ProjectLatestActivityProjection::getLastActivityAt + )); + + assertThat(lastActivityAtByProjectId) + .containsEntry(firstProject.getId(), firstProjectLatest) + .containsEntry(secondProject.getId(), secondProjectLatest); + } + + @Test + void 활동이_없는_프로젝트는_최신_활동_집계에서_제외한다() { + // 활동 이력이 없는 카드는 lastActivityAt=null로 내려가므로 FE가 경과 시간 문구를 숨길 수 있어야 한다. + Users owner = userRepository.save(Users.createSocialUser( + "green@example.com", "그린", null, SocialType.GOOGLE, "google-green" + )); + Project projectWithoutActivity = saveProject(owner, "활동 없는 프로젝트"); + + List projections = activityLogRepository + .findLatestActivityAtByProjectIds(List.of(projectWithoutActivity.getId())); + + assertThat(projections).isEmpty(); + } + + private Project saveProject(Users owner, String title) { + return projectRepository.save(Project.create( + owner, + title, + CategoryName.DOCUMENTARY, + LengthType.SHORT_FORM, + "프로젝트 목록 최신 활동 시각 검증용 설명입니다.", + LocalDate.now().plusDays(1), + null, + Kind.PERSONAL + )); + } + + private void saveActivity(Project project, Users actor, LocalDateTime createdAt) { + ActivityLog activityLog = activityLogRepository.save(ActivityLog.create( + project, + ActivityActor.user(actor.getId(), actor.getNickname()), + ActivityLogType.FILE_UPLOADED, + "차태훈님이 파일을 등록했어요.", + ActivityLogTargetType.FILE, + 1L + )); + ReflectionTestUtils.setField(activityLog, "createdAt", createdAt); + } +} From 086d0a7a24c04062d7c6333bea879be869b198f3 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 6 Aug 2026 00:19:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + .../user/controller/UserController.java | 16 ++ .../user/dto/UserProfileImageResponse.java | 15 ++ .../com/slatto/domain/user/entity/Users.java | 6 +- .../domain/user/exception/UserErrorCode.java | 5 +- .../domain/user/service/UserService.java | 141 ++++++++++++++++++ src/main/resources/application.yml | 1 + .../user/service/PortfolioUpdatedAtTest.java | 6 + .../service/UserProfileImageServiceTest.java | 100 +++++++++++++ src/test/resources/application.yml | 1 + 10 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/slatto/domain/user/dto/UserProfileImageResponse.java create mode 100644 src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java diff --git a/.env.example b/.env.example index f17e485b..5c4a3c2a 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,7 @@ AWS_SECRET_ACCESS_KEY= # S3 bucket CLOUD_AWS_S3_BUCKET= + +# 프로필 이미지 등 공개 파일 URL을 조합할 CDN 또는 공개 S3 base URL +# 예: https://cdn.slatto.cloud +CLOUD_AWS_S3_PUBLIC_BASE_URL= diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index 4e1738bf..c53d6631 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -5,6 +5,7 @@ import com.slatto.domain.user.dto.UserOnboardingResponse; import com.slatto.domain.user.dto.UserProfileUpdateRequest; import com.slatto.domain.user.dto.UserProfileUpdateResponse; +import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; import com.slatto.domain.user.service.UserService; import com.slatto.global.response.ApiResponse; @@ -20,7 +21,11 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "User", description = "유저 API") @RestController @@ -60,6 +65,17 @@ public ApiResponse updateProfile( return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation(summary = "프로필 이미지 업로드", description = "프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다.") + @PutMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadProfileImage( + @AuthenticationPrincipal Long userId, + @RequestPart("file") MultipartFile file + ) { + UserProfileImageResponse response = userService.uploadProfileImage(userId, file); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + @Operation(summary = "공개 프로필 조회", description = "다른 유저의 공개 프로필을 조회한다. 이메일 등 비공개 필드는 제외된다.") @GetMapping("/{userId}") public ApiResponse getPublicProfile(@PathVariable Long userId) { diff --git a/src/main/java/com/slatto/domain/user/dto/UserProfileImageResponse.java b/src/main/java/com/slatto/domain/user/dto/UserProfileImageResponse.java new file mode 100644 index 00000000..309e879d --- /dev/null +++ b/src/main/java/com/slatto/domain/user/dto/UserProfileImageResponse.java @@ -0,0 +1,15 @@ +package com.slatto.domain.user.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserProfileImageResponse { + + private String profileImageUrl; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/slatto/domain/user/entity/Users.java b/src/main/java/com/slatto/domain/user/entity/Users.java index 0dd490a2..8b0813c5 100644 --- a/src/main/java/com/slatto/domain/user/entity/Users.java +++ b/src/main/java/com/slatto/domain/user/entity/Users.java @@ -98,4 +98,8 @@ public void completeOnboarding(String nickname, String bio, String profileImageU this.onboardingCompleted = true; } -} \ No newline at end of file + public void updateProfileImage(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + +} diff --git a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java index b77065ba..6acb3cf6 100644 --- a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java +++ b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java @@ -9,7 +9,10 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseCode { - ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다."); + ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다."), + PROFILE_IMAGE_EMPTY(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_EMPTY400", "업로드할 프로필 이미지가 비어 있습니다."), + PROFILE_IMAGE_INVALID_TYPE(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_INVALID_TYPE400", "지원하지 않는 프로필 이미지 형식입니다."), + PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 10MB까지 업로드할 수 있습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 1e9762f6..292a29dd 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -5,6 +5,7 @@ import com.slatto.domain.user.dto.UserOnboardingResponse; import com.slatto.domain.user.dto.UserProfileUpdateRequest; import com.slatto.domain.user.dto.UserProfileUpdateResponse; +import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; import com.slatto.domain.user.entity.Location; import com.slatto.domain.user.entity.UserCategory; @@ -20,21 +21,45 @@ import com.slatto.domain.user.repository.UserRoleRepository; import com.slatto.global.exception.BaseException; import com.slatto.global.response.code.CommonErrorCode; +import com.slatto.global.storage.StorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class UserService { + private static final long MAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024; + private static final String PROFILE_IMAGE_STORAGE_KEY_FORMAT = "users/%d/profile-images/%s.%s"; + private static final Map> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( + "image/jpeg", Set.of("jpg", "jpeg"), + "image/png", Set.of("png"), + "image/webp", Set.of("webp") + ); + private final UserRepository userRepository; private final UserRoleRepository userRoleRepository; private final UserCategoryRepository userCategoryRepository; private final LocationRepository locationRepository; + private final StorageService storageService; + + @Value("${cloud.aws.s3.public-base-url:}") + private String publicBaseUrl; public UserMeResponse getMyInfo(Long userId) { Users user = getUserOrThrow(userId); @@ -185,6 +210,28 @@ public UserProfileUpdateResponse updateProfile(Long userId, UserProfileUpdateReq .build(); } + @Transactional + public UserProfileImageResponse uploadProfileImage(Long userId, MultipartFile file) { + Users user = getUserOrThrow(userId); + validateProfileImage(file); + + String storageKey = createProfileImageStorageKey(userId, file.getOriginalFilename()); + String profileImageUrl = createProfileImageUrl(storageKey); + String previousStorageKey = extractManagedStorageKey(user.getProfileImageUrl()); + + storageService.upload(file, storageKey); + registerUploadedFileCleanupOnRollback(storageKey); + + user.updateProfileImage(profileImageUrl); + userRepository.flush(); + registerPreviousFileDeletionAfterCommit(previousStorageKey); + + return UserProfileImageResponse.builder() + .profileImageUrl(profileImageUrl) + .updatedAt(user.getUpdatedAt()) + .build(); + } + public UserPublicProfileResponse getPublicProfile(Long userId) { Users user = getUserOrThrow(userId); @@ -215,6 +262,100 @@ public UserPublicProfileResponse getPublicProfile(Long userId) { .build(); } + private void validateProfileImage(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BaseException(UserErrorCode.PROFILE_IMAGE_EMPTY); + } + + if (file.getSize() > MAX_PROFILE_IMAGE_SIZE) { + throw new BaseException(UserErrorCode.PROFILE_IMAGE_SIZE_EXCEEDED); + } + + String extension = getExtension(file.getOriginalFilename()); + String contentType = file.getContentType(); + if (!isAllowedProfileImage(contentType, extension)) { + throw new BaseException(UserErrorCode.PROFILE_IMAGE_INVALID_TYPE); + } + } + + private boolean isAllowedProfileImage(String contentType, String extension) { + if (!StringUtils.hasText(contentType) || !StringUtils.hasText(extension)) { + return false; + } + + return ALLOWED_EXTENSIONS_BY_CONTENT_TYPE + .getOrDefault(contentType.toLowerCase(Locale.ROOT), Set.of()) + .contains(extension); + } + + private String createProfileImageStorageKey(Long userId, String originalFilename) { + return PROFILE_IMAGE_STORAGE_KEY_FORMAT.formatted(userId, UUID.randomUUID(), getExtension(originalFilename)); + } + + private String createProfileImageUrl(String storageKey) { + if (!StringUtils.hasText(publicBaseUrl)) { + throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + + return publicBaseUrl.replaceAll("/+$", "") + "/" + storageKey; + } + + private String extractManagedStorageKey(String profileImageUrl) { + if (!StringUtils.hasText(publicBaseUrl) || !StringUtils.hasText(profileImageUrl)) { + return null; + } + + String normalizedBaseUrl = publicBaseUrl.replaceAll("/+$", "") + "/"; + if (!profileImageUrl.startsWith(normalizedBaseUrl)) { + return null; + } + + return profileImageUrl.substring(normalizedBaseUrl.length()); + } + + private void registerUploadedFileCleanupOnRollback(String storageKey) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status != STATUS_COMMITTED) { + deleteStorageObjectQuietly(storageKey, "profile image upload rollback"); + } + } + }); + } + + private void registerPreviousFileDeletionAfterCommit(String previousStorageKey) { + if (!StringUtils.hasText(previousStorageKey) || !TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_COMMITTED) { + deleteStorageObjectQuietly(previousStorageKey, "profile image replacement"); + } + } + }); + } + + private void deleteStorageObjectQuietly(String storageKey, String context) { + try { + storageService.delete(storageKey); + } catch (RuntimeException exception) { + log.warn("Failed to delete S3 object after {}. storageKey={}", context, storageKey, exception); + } + } + + private String getExtension(String fileName) { + String extension = StringUtils.getFilenameExtension(fileName); + return StringUtils.hasText(extension) ? extension.toLowerCase(Locale.ROOT) : ""; + } + private Users getUserOrThrow(Long userId) { return userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d7e10636..d4a8cab3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,7 @@ cloud: aws: s3: bucket: ${CLOUD_AWS_S3_BUCKET} + public-base-url: ${CLOUD_AWS_S3_PUBLIC_BASE_URL:} youtube: api: diff --git a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java index c76f557b..59038e36 100644 --- a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java +++ b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java @@ -16,12 +16,14 @@ import com.slatto.domain.user.repository.UserPortfolioRoleRepository; import com.slatto.domain.user.repository.UserRepository; import com.slatto.domain.video.util.YoutubeUrlParser; +import com.slatto.global.storage.StorageService; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.TestPropertySource; @@ -41,6 +43,10 @@ class PortfolioUpdatedAtTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // UserService가 프로필 이미지 업로드용 StorageService를 의존하므로, 이 JPA 슬라이스에서는 빈만 제공한다. + @MockBean + private StorageService storageService; + @Autowired private PortfolioService portfolioService; diff --git a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java new file mode 100644 index 00000000..8abbfcb7 --- /dev/null +++ b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java @@ -0,0 +1,100 @@ +package com.slatto.domain.user.service; + +import com.slatto.domain.user.dto.UserProfileImageResponse; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.LocationRepository; +import com.slatto.domain.user.repository.UserCategoryRepository; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.domain.user.repository.UserRoleRepository; +import com.slatto.global.exception.BaseException; +import com.slatto.global.storage.StorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserProfileImageServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserRoleRepository userRoleRepository; + + @Mock + private UserCategoryRepository userCategoryRepository; + + @Mock + private LocationRepository locationRepository; + + @Mock + private StorageService storageService; + + @InjectMocks + private UserService userService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(userService, "publicBaseUrl", "https://cdn.slatto.cloud"); + } + + @Test + void PNG_프로필_이미지를_사용자별_S3_경로에_업로드하고_CDN_URL을_반환한다() { + // 프로필 이미지는 프로젝트 파일 경로와 분리하고, FE에는 storageKey 대신 바로 사용할 CDN URL을 내려준다. + Users user = user(1L, null); + MockMultipartFile file = new MockMultipartFile( + "file", "green.png", "image/png", "profile-image".getBytes() + ); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + UserProfileImageResponse response = userService.uploadProfileImage(1L, file); + + ArgumentCaptor storageKeyCaptor = ArgumentCaptor.forClass(String.class); + verify(storageService).upload(eq(file), storageKeyCaptor.capture()); + assertThat(storageKeyCaptor.getValue()).matches("users/1/profile-images/[a-f0-9-]+\\.png"); + assertThat(response.getProfileImageUrl()) + .isEqualTo("https://cdn.slatto.cloud/" + storageKeyCaptor.getValue()); + assertThat(user.getProfileImageUrl()).isEqualTo(response.getProfileImageUrl()); + } + + @Test + void 허용하지_않은_이미지_형식은_S3에_업로드하지_않는다() { + // MIME 타입과 확장자가 허용 목록에 없으면 저장 전 요청을 차단해야 한다. + Users user = user(1L, null); + MockMultipartFile file = new MockMultipartFile( + "file", "green.gif", "image/gif", "profile-image".getBytes() + ); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.uploadProfileImage(1L, file)) + .isInstanceOf(BaseException.class); + + verify(storageService, never()).upload(any(), anyString()); + } + + private Users user(Long id, String profileImageUrl) { + Users user = Users.createSocialUser( + "green@example.com", "그린", profileImageUrl, SocialType.GOOGLE, "google-green" + ); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index cb9b480c..cf927281 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -20,6 +20,7 @@ cloud: aws: s3: bucket: test-bucket + public-base-url: https://cdn.test.example youtube: api: From bebbfb77599075e8e4d6ad7b96c11cc72789b546 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 5 Aug 2026 23:40:35 +0900 Subject: [PATCH 3/7] =?UTF-8?q?build:=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ src/main/resources/application.yml | 7 +++++++ src/test/resources/application.yml | 3 +++ 3 files changed, 12 insertions(+) diff --git a/build.gradle b/build.gradle index b549fca8..0418a096 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' implementation 'me.paulschwarz:spring-dotenv:4.0.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d4a8cab3..89972e6a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,13 @@ spring: hibernate: format_sql: true + flyway: + # 기존 운영/개발 DB는 수동 반영된 V8 스키마를 기준점으로 등록한 뒤 이후 변경만 적용한다. + baseline-on-migrate: true + baseline-version: 8 + baseline-description: legacy-schema + validate-on-migrate: true + server: forward-headers-strategy: framework diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index cf927281..1db07238 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -16,6 +16,9 @@ spring: hibernate: ddl-auto: create-drop + flyway: + enabled: false + cloud: aws: s3: From 75cd92a67db8d05f3e5358e481eebb51fdfb2ce8 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 5 Aug 2026 23:40:35 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=B2=B4?= =?UTF-8?q?=EA=B3=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../005-project-member-activity-read.sql | 7 ------- ...e-login.sql => V001__auth_google_login.sql} | 2 +- ...2-project-pin.sql => V002__project_pin.sql} | 0 ...-read.sql => V003__project_notice_read.sql} | 0 ....sql => V004__notification_group_count.sql} | 0 ...ent-crud.sql => V005__recruitment_crud.sql} | 0 ...> V006__recruitment_application_status.sql} | 0 ...=> V007__notification_project_nullable.sql} | 0 ..._recruitment_application_active_unique.sql} | 0 .../db/migration/V009__activity_log_type.sql | 5 +++++ ...ead.sql => V010__project_activity_read.sql} | 18 +++++++++++++++++- 11 files changed, 23 insertions(+), 9 deletions(-) delete mode 100644 src/main/resources/db/migration/005-project-member-activity-read.sql rename src/main/resources/db/migration/{001-auth-google-login.sql => V001__auth_google_login.sql} (82%) rename src/main/resources/db/migration/{002-project-pin.sql => V002__project_pin.sql} (100%) rename src/main/resources/db/migration/{003-project-notice-read.sql => V003__project_notice_read.sql} (100%) rename src/main/resources/db/migration/{004-notification-group-count.sql => V004__notification_group_count.sql} (100%) rename src/main/resources/db/migration/{005-recruitment-crud.sql => V005__recruitment_crud.sql} (100%) rename src/main/resources/db/migration/{006-recruitment-application-status.sql => V006__recruitment_application_status.sql} (100%) rename src/main/resources/db/migration/{007-notification-project-nullable.sql => V007__notification_project_nullable.sql} (100%) rename src/main/resources/db/migration/{008-recruitment-application-active-unique.sql => V008__recruitment_application_active_unique.sql} (100%) create mode 100644 src/main/resources/db/migration/V009__activity_log_type.sql rename src/main/resources/db/migration/{006-project-activity-read.sql => V010__project_activity_read.sql} (51%) diff --git a/src/main/resources/db/migration/005-project-member-activity-read.sql b/src/main/resources/db/migration/005-project-member-activity-read.sql deleted file mode 100644 index b93744dc..00000000 --- a/src/main/resources/db/migration/005-project-member-activity-read.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 프로젝트 멤버별 최근활동 확인 시각과 최근활동 목록 조회 인덱스를 추가한다. - -ALTER TABLE project_member - ADD COLUMN last_activity_read_at DATETIME(6) NULL; - -CREATE INDEX idx_activity_log_project_created_id - ON activity_log (project_id, created_at, id); diff --git a/src/main/resources/db/migration/001-auth-google-login.sql b/src/main/resources/db/migration/V001__auth_google_login.sql similarity index 82% rename from src/main/resources/db/migration/001-auth-google-login.sql rename to src/main/resources/db/migration/V001__auth_google_login.sql index 90a16856..e2c5fded 100644 --- a/src/main/resources/db/migration/001-auth-google-login.sql +++ b/src/main/resources/db/migration/V001__auth_google_login.sql @@ -1,4 +1,4 @@ --- ddl-auto=validate 이므로 애플리케이션 기동 전에 직접 적용해야 한다. +-- Google OAuth 로그인 도입에 필요한 사용자 온보딩 상태와 리프레시 토큰 테이블을 추가한다. ALTER TABLE users ADD COLUMN onboarding_completed BIT(1) NOT NULL DEFAULT b'0'; diff --git a/src/main/resources/db/migration/002-project-pin.sql b/src/main/resources/db/migration/V002__project_pin.sql similarity index 100% rename from src/main/resources/db/migration/002-project-pin.sql rename to src/main/resources/db/migration/V002__project_pin.sql diff --git a/src/main/resources/db/migration/003-project-notice-read.sql b/src/main/resources/db/migration/V003__project_notice_read.sql similarity index 100% rename from src/main/resources/db/migration/003-project-notice-read.sql rename to src/main/resources/db/migration/V003__project_notice_read.sql diff --git a/src/main/resources/db/migration/004-notification-group-count.sql b/src/main/resources/db/migration/V004__notification_group_count.sql similarity index 100% rename from src/main/resources/db/migration/004-notification-group-count.sql rename to src/main/resources/db/migration/V004__notification_group_count.sql diff --git a/src/main/resources/db/migration/005-recruitment-crud.sql b/src/main/resources/db/migration/V005__recruitment_crud.sql similarity index 100% rename from src/main/resources/db/migration/005-recruitment-crud.sql rename to src/main/resources/db/migration/V005__recruitment_crud.sql diff --git a/src/main/resources/db/migration/006-recruitment-application-status.sql b/src/main/resources/db/migration/V006__recruitment_application_status.sql similarity index 100% rename from src/main/resources/db/migration/006-recruitment-application-status.sql rename to src/main/resources/db/migration/V006__recruitment_application_status.sql diff --git a/src/main/resources/db/migration/007-notification-project-nullable.sql b/src/main/resources/db/migration/V007__notification_project_nullable.sql similarity index 100% rename from src/main/resources/db/migration/007-notification-project-nullable.sql rename to src/main/resources/db/migration/V007__notification_project_nullable.sql diff --git a/src/main/resources/db/migration/008-recruitment-application-active-unique.sql b/src/main/resources/db/migration/V008__recruitment_application_active_unique.sql similarity index 100% rename from src/main/resources/db/migration/008-recruitment-application-active-unique.sql rename to src/main/resources/db/migration/V008__recruitment_application_active_unique.sql diff --git a/src/main/resources/db/migration/V009__activity_log_type.sql b/src/main/resources/db/migration/V009__activity_log_type.sql new file mode 100644 index 00000000..f20466c7 --- /dev/null +++ b/src/main/resources/db/migration/V009__activity_log_type.sql @@ -0,0 +1,5 @@ +-- 과거 activity_log.type 컬럼이 제작 역할(RoleName) enum으로 생성된 문제를 바로잡는다. +-- 이후 ActivityLogType enum 값(PROJECT_UPDATED, VIDEO_FEEDBACK_COMMENTED 등)을 문자열로 저장한다. + +ALTER TABLE activity_log + MODIFY COLUMN type VARCHAR(50) NOT NULL; diff --git a/src/main/resources/db/migration/006-project-activity-read.sql b/src/main/resources/db/migration/V010__project_activity_read.sql similarity index 51% rename from src/main/resources/db/migration/006-project-activity-read.sql rename to src/main/resources/db/migration/V010__project_activity_read.sql index c3112c70..28e7aa2d 100644 --- a/src/main/resources/db/migration/006-project-activity-read.sql +++ b/src/main/resources/db/migration/V010__project_activity_read.sql @@ -1,9 +1,25 @@ -- 프로젝트 멤버별 최근활동 읽음 상태를 활동 로그 단위로 저장한다. --- 기존 확인 시각 컬럼이 적용된 DB에서는 개별 읽음 테이블로 전환한다. +-- 이전 확인 시각 방식이 일부 DB에 반영된 경우를 함께 정리한다. ALTER TABLE project_member DROP COLUMN IF EXISTS last_activity_read_at; +SET @activity_log_index_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'activity_log' + AND index_name = 'idx_activity_log_project_created_id' +); +SET @create_activity_log_index = IF( + @activity_log_index_exists = 0, + 'CREATE INDEX idx_activity_log_project_created_id ON activity_log (project_id, created_at, id)', + 'SELECT 1' +); +PREPARE activity_log_index_statement FROM @create_activity_log_index; +EXECUTE activity_log_index_statement; +DEALLOCATE PREPARE activity_log_index_statement; + CREATE TABLE project_activity_read ( id BIGINT NOT NULL AUTO_INCREMENT, project_member_id BIGINT NOT NULL, From e8b9c9554fd7df3f086675e692ed5be9d29b4cb7 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 6 Aug 2026 01:50:50 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20multipart?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/slatto/domain/user/service/UserService.java | 7 ++++++- src/main/resources/application.yml | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 292a29dd..c51529dc 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -219,7 +219,12 @@ public UserProfileImageResponse uploadProfileImage(Long userId, MultipartFile fi String profileImageUrl = createProfileImageUrl(storageKey); String previousStorageKey = extractManagedStorageKey(user.getProfileImageUrl()); - storageService.upload(file, storageKey); + try { + storageService.upload(file, storageKey); + } catch (RuntimeException exception) { + deleteStorageObjectQuietly(storageKey, "profile image upload"); + throw exception; + } registerUploadedFileCleanupOnRollback(storageKey); user.updateProfileImage(profileImageUrl); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 89972e6a..a38f6ee5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,8 +4,8 @@ spring: servlet: multipart: - max-file-size: 100MB - max-request-size: 105MB + max-file-size: 10MB + max-request-size: 10MB datasource: driver-class-name: com.mysql.cj.jdbc.Driver From 38b58c391fc4b966904cdf9bbc081db1d1ec5780 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 6 Aug 2026 01:50:56 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A7=80=EC=9B=90=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...8__recruitment_application_active_unique.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/resources/db/migration/V008__recruitment_application_active_unique.sql b/src/main/resources/db/migration/V008__recruitment_application_active_unique.sql index 45817c71..e7fec303 100644 --- a/src/main/resources/db/migration/V008__recruitment_application_active_unique.sql +++ b/src/main/resources/db/migration/V008__recruitment_application_active_unique.sql @@ -3,6 +3,23 @@ -- deleted_at 이 있는 행은 active_user_id 가 NULL 이 되고 MySQL 유니크 인덱스는 NULL 을 서로 다른 값으로 -- 취급하므로, 단순 (recruitment_id, user_id) 유니크와 달리 지원 취소 후 재지원이 차단되지 않는다. +-- 기존에 이미 중복된 활성 지원이 있는 경우, 가장 이른 행만 유지하고 나머지는 soft-delete 한다. +UPDATE recruitment_application ra +JOIN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY recruitment_id, user_id + ORDER BY id ASC + ) AS rn + FROM recruitment_application + WHERE deleted_at IS NULL + ) ranked + WHERE rn > 1 +) dup ON dup.id = ra.id +SET ra.deleted_at = CURRENT_TIMESTAMP; + ALTER TABLE recruitment_application ADD COLUMN active_user_id BIGINT GENERATED ALWAYS AS (IF(deleted_at IS NULL, user_id, NULL)) VIRTUAL; From dfddb7be7d88367110b97dbadfecaa8a82837753 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 6 Aug 2026 01:51:01 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20activity=5Flog=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../db/migration/V009__activity_log_type.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/resources/db/migration/V009__activity_log_type.sql b/src/main/resources/db/migration/V009__activity_log_type.sql index f20466c7..dc88f1db 100644 --- a/src/main/resources/db/migration/V009__activity_log_type.sql +++ b/src/main/resources/db/migration/V009__activity_log_type.sql @@ -1,5 +1,23 @@ -- 과거 activity_log.type 컬럼이 제작 역할(RoleName) enum으로 생성된 문제를 바로잡는다. -- 이후 ActivityLogType enum 값(PROJECT_UPDATED, VIDEO_FEEDBACK_COMMENTED 등)을 문자열로 저장한다. +-- 기존 레거시 값이 있으면 호환 가능한 문자열로 정규화한다. +UPDATE activity_log +SET type = CASE + WHEN type IN ( + 'PROJECT_MEMBER_JOINED', + 'PROJECT_STATUS_CHANGED', + 'PROJECT_UPDATED', + 'SCHEDULE_CREATED', + 'SCHEDULE_UPDATED', + 'NOTICE_CREATED', + 'FILE_UPLOADED', + 'VIDEO_FEEDBACK_COMMENTED' + ) THEN type + WHEN type IN ('DIRECTOR', 'PD', 'CINEMATOGRAPHER', 'EDITOR', 'ART', 'SOUND', 'WRITER', 'LIGHTING', 'ACTOR', 'ETC') THEN 'PROJECT_UPDATED' + ELSE 'PROJECT_UPDATED' +END +WHERE type IS NOT NULL; + ALTER TABLE activity_log MODIFY COLUMN type VARCHAR(50) NOT NULL;