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/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/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/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..c51529dc 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,33 @@ 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()); + + try { + storageService.upload(file, storageKey); + } catch (RuntimeException exception) { + deleteStorageObjectQuietly(storageKey, "profile image upload"); + throw exception; + } + 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 +267,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..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 @@ -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 @@ -36,6 +43,7 @@ cloud: aws: s3: bucket: ${CLOUD_AWS_S3_BUCKET} + public-base-url: ${CLOUD_AWS_S3_PUBLIC_BASE_URL:} youtube: api: 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 58% 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 index 45817c71..e7fec303 100644 --- a/src/main/resources/db/migration/008-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; 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..dc88f1db --- /dev/null +++ b/src/main/resources/db/migration/V009__activity_log_type.sql @@ -0,0 +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; 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, 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); + } +} 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..1db07238 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -16,10 +16,14 @@ spring: hibernate: ddl-auto: create-drop + flyway: + enabled: false + cloud: aws: s3: bucket: test-bucket + public-base-url: https://cdn.test.example youtube: api: