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/feedback/service/FeedbackDetailService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java index 9fbe33ce..1001439a 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java @@ -21,6 +21,8 @@ import com.slatto.domain.sharelink.entity.ShareLink; import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; import com.slatto.domain.sharelink.repository.GuestRepository; +import com.slatto.domain.notification.service.NotificationService; +import com.slatto.domain.video.entity.Video; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.exception.BaseException; @@ -39,6 +41,7 @@ public class FeedbackDetailService { private final GuestRepository guestRepository; private final FeedbackDetailConverter feedbackDetailConverter; private final ProjectMemberRepository projectMemberRepository; + private final NotificationService notificationService; private final ActivityLogService activityLogService; private static final int DEFAULT_PAGE_SIZE = 10; @@ -71,6 +74,12 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe FeedbackDetail reply = feedbackDetailConverter.toFeedbackDetail(feedback, user, guest, req); FeedbackDetail saved = feedbackDetailRepository.save(reply); + // 5. 프로젝트 멤버에게 답글 알림 발송 (작성자 본인은 actorUserId로 제외) + // 알림 문구 조합용 작성자명 — 회원이면 유저명, 게스트면 게스트명 + String commenterName = (user != null) ? user.getNickname() : guest.getName(); + sendReplyNotification(feedback.getVideo(), userId, commenterName); + + // 6. 최근 활동 로그 기록 (회원/게스트 구분) if (user != null) { activityLogService.createVideoFeedbackCommentedLog( feedback.getVideo().getProject().getId(), @@ -90,6 +99,29 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe return feedbackDetailConverter.toCreateResponse(saved); } + // 답글 생성 시 프로젝트 멤버에게 알림 발송 + // 문구 조합/저장/그룹핑/작성자 제외는 알림 도메인이 처리하므로 재료(영상명·작성자명)만 준비해 호출한다. + // 원 피드백의 영상 기준으로 그룹핑되므로 targetId는 videoId가 사용된다. + private void sendReplyNotification(Video video, Long actorUserId, String commenterName) { + Long projectId = video.getProject().getId(); + + // 프로젝트 활성 멤버 전체를 수신자로 (작성자 제외는 actorUserId로 알림 도메인이 처리) + List recipientIds = projectMemberRepository + .findAllActiveMembersByProjectId(projectId) + .stream() + .map(pm -> pm.getUser().getId()) + .toList(); + + notificationService.createVideoFeedbackCommentedNotifications( + projectId, + video.getId(), + video.getTitle(), // 영상명 → 알림 도메인이 문구 조합에 사용 + commenterName, // 작성자명 → 알림 도메인이 문구 조합에 사용 + recipientIds, + actorUserId // 게스트면 null → 제외 대상 없음 + ); + } + private void validateWriter(Long userId, Long guestId) { boolean hasUser = (userId != null); boolean hasGuest = (guestId != null); @@ -242,4 +274,4 @@ public ReplyStatusResDTO changeReplyStatus(Long replyId, Long userId, ReplyStatu return feedbackDetailConverter.toStatusResponse(reply); } -} +} \ No newline at end of file diff --git a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java index 9c99b126..f09e8639 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java @@ -13,6 +13,7 @@ import com.slatto.domain.project.repository.ProjectMemberRepository; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import com.slatto.domain.notification.service.NotificationService; import java.util.HashMap; import java.util.List; @@ -46,6 +47,7 @@ public class FeedbackService { private final ObjectProvider entityManagerProvider; private final ProjectMemberRepository projectMemberRepository; private final FeedbackDetailRepository feedbackDetailRepository; + private final NotificationService notificationService; private final ActivityLogService activityLogService; private static final int DEFAULT_PAGE_SIZE = 10; @@ -82,6 +84,12 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr Feedback feedback = feedbackConverter.toFeedback(video, user, guest, req); Feedback saved = feedbackRepository.save(feedback); + // 5. 프로젝트 멤버에게 피드백 알림 발송 (작성자 본인은 actorUserId로 제외) + // 알림 문구 조합용 작성자명 — 회원이면 유저명, 게스트면 게스트명 + String commenterName = (user != null) ? user.getNickname() : guest.getName(); + sendFeedbackNotification(video, userId, commenterName); + + // 6. 최근 활동 로그 기록 (회원/게스트 구분) if (user != null) { activityLogService.createVideoFeedbackCommentedLog( video.getProject().getId(), @@ -101,6 +109,28 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr return feedbackConverter.toCreateResponse(saved); } + // 피드백/답글 생성 시 프로젝트 멤버에게 알림을 보낸다. + // 문구 조합/저장/그룹핑/작성자 제외는 알림 도메인이 처리하므로 재료(영상명·작성자명)만 준비해 호출한다. + private void sendFeedbackNotification(Video video, Long actorUserId, String commenterName) { + Long projectId = video.getProject().getId(); + + // 프로젝트 활성 멤버 전체를 수신자로 (작성자 제외는 actorUserId로 알림 도메인이 처리) + List recipientIds = projectMemberRepository + .findAllActiveMembersByProjectId(projectId) + .stream() + .map(pm -> pm.getUser().getId()) + .toList(); + + notificationService.createVideoFeedbackCommentedNotifications( + projectId, + video.getId(), + video.getTitle(), // 영상명 → 알림 도메인이 문구 조합에 사용 + commenterName, // 작성자명 → 알림 도메인이 문구 조합에 사용 + recipientIds, + actorUserId // 게스트면 null → 제외 대상 없음 + ); + } + @Transactional public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, FeedbackUpdateReqDTO req) { @@ -296,4 +326,5 @@ public FeedbackStatusResDTO changeFeedbackStatus(Long feedbackId, Long userId, F return feedbackConverter.toStatusResponse(feedback); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/slatto/domain/notification/enums/NotificationTargetType.java b/src/main/java/com/slatto/domain/notification/enums/NotificationTargetType.java index 4237234f..6db65231 100644 --- a/src/main/java/com/slatto/domain/notification/enums/NotificationTargetType.java +++ b/src/main/java/com/slatto/domain/notification/enums/NotificationTargetType.java @@ -8,5 +8,7 @@ public enum NotificationTargetType { SCHEDULE, PROJECT, VIDEO, - RECRUITMENT + RECRUITMENT, + NOTICE, + PROJECT_FILE } diff --git a/src/main/java/com/slatto/domain/notification/enums/NotificationType.java b/src/main/java/com/slatto/domain/notification/enums/NotificationType.java index 04c4a3d4..c5fa5d99 100644 --- a/src/main/java/com/slatto/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/slatto/domain/notification/enums/NotificationType.java @@ -2,7 +2,7 @@ public enum NotificationType { SCHEDULE_ASSIGNED, - PROJECT_INVITED, + PROJECT_JOINED, VIDEO_FEEDBACK_COMMENTED, RECRUITMENT_APPLIED, SCHEDULE_CREATED, 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/NotificationRepository.java b/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java index 84802b7c..5bbe6304 100644 --- a/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/slatto/domain/notification/repository/NotificationRepository.java @@ -124,22 +124,22 @@ List findUnreadGroupedNotificationsForUpdate( Pageable pageable ); - // 미읽음 그룹 알림이 없으면 누적 개수는 0이다. @Query(""" - select coalesce(max(n.groupCount), 0) + select n.targetId, coalesce(max(n.groupCount), 0) from Notification n where n.user.id = :userId and n.type = :type and n.targetType = :targetType - and n.targetId = :targetId + and n.targetId in :targetIds and n.isRead = false and n.deletedAt is null + group by n.targetId """) - int findUnreadGroupedNotificationCount( + List findUnreadGroupedNotificationCounts( @Param("userId") Long userId, @Param("type") NotificationType type, @Param("targetType") String targetType, - @Param("targetId") Long targetId + @Param("targetIds") List targetIds ); @Modifying(clearAutomatically = true, flushAutomatically = true) 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/notification/service/NotificationService.java b/src/main/java/com/slatto/domain/notification/service/NotificationService.java index b1e75a93..eb96b58b 100644 --- a/src/main/java/com/slatto/domain/notification/service/NotificationService.java +++ b/src/main/java/com/slatto/domain/notification/service/NotificationService.java @@ -20,8 +20,10 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import java.util.function.IntFunction; @Service @@ -101,18 +103,24 @@ public void markAllNotificationsAsRead(Long currentUserId) { } /** - * 영상 카드의 미읽은 피드백 개수에 사용할 그룹 알림 누적 개수를 조회한다. + * 영상 목록의 미읽은 피드백 누적 개수를 한 번에 조회한다. */ - public int getUnreadVideoFeedbackCount(Long currentUserId, Long videoId) { + public Map getUnreadVideoFeedbackCounts(Long currentUserId, List videoIds) { validateActiveUser(currentUserId); - validateRequiredId(videoId); + if (videoIds.isEmpty()) { + return Map.of(); + } - return notificationRepository.findUnreadGroupedNotificationCount( - currentUserId, - NotificationType.VIDEO_FEEDBACK_COMMENTED, - NotificationTargetType.VIDEO.name(), - videoId - ); + return notificationRepository.findUnreadGroupedNotificationCounts( + currentUserId, + NotificationType.VIDEO_FEEDBACK_COMMENTED, + NotificationTargetType.VIDEO.name(), + videoIds + ).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).intValue() + )); } @Transactional @@ -184,70 +192,91 @@ public void createScheduleAssignedNotifications( List recipients, Long writerId ) { - createNotifications(NotificationCreateCommand.builder() - .recipientIds(recipients.stream() - .map(Users::getId) - .toList()) - .projectId(project != null ? project.getId() : null) - .type(NotificationType.SCHEDULE_ASSIGNED) - .title(createScheduleAssignedTitle(project != null ? project.getTitle() : null)) - .content(createScheduleAssignedContent(scheduleTitle)) - .targetType(NotificationTargetType.SCHEDULE) - .targetId(scheduleId) - .excludeUserId(writerId) - .build()); + validateRequiredId(scheduleId); + validateRequiredId(writerId); + validateRequiredText(scheduleTitle); + if (recipients == null) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } + + Project notificationProject = getProjectOrNull(project != null ? project.getId() : null); + String title = createScheduleAssignedTitle(project != null ? project.getTitle() : null); + String targetType = getTargetTypeName(NotificationTargetType.SCHEDULE); + Set recipientIds = new HashSet<>(); + + List notifications = recipients.stream() + .filter(Objects::nonNull) + .filter(recipient -> recipientIds.add(recipient.getId())) + .filter(recipient -> !Objects.equals(recipient.getId(), writerId)) + .map(recipient -> Notification.create( + recipient, + notificationProject, + NotificationType.SCHEDULE_ASSIGNED, + title, + createScheduleAssignedContent(scheduleTitle, recipient.getNickname()), + targetType, + scheduleId + )) + .toList(); + + notificationRepository.saveAll(notifications); } /** - * 프로젝트 초대 알림을 생성한다. - * 클릭 대상은 프로젝트 상세 화면이다. - * - * @deprecated 알림 문구는 알림 도메인에서 관리하므로 projectTitle을 받는 메서드를 사용한다. + * 프로젝트 합류 알림을 프로젝트 참여자에게 생성한다. + * 합류자 본인도 수신 대상에 포함할 수 있다. */ - @Deprecated @Transactional - public void createProjectInvitationNotification( - Long recipientId, + public void createProjectJoinedNotifications( Long projectId, - String content + String projectTitle, + String joinerName, + List recipientIds ) { - validateRequiredId(recipientId); validateRequiredId(projectId); + validateRequiredText(projectTitle); + validateRequiredText(joinerName); - createNotification(NotificationCreateCommand.builder() - .recipientIds(List.of(recipientId)) + createNotifications(NotificationCreateCommand.builder() + .recipientIds(recipientIds) .projectId(projectId) - .type(NotificationType.PROJECT_INVITED) - .title(createFallbackTitle(NotificationType.PROJECT_INVITED)) - .content(content) + .type(NotificationType.PROJECT_JOINED) + .title(createProjectJoinedTitle(projectTitle)) + .content(createProjectJoinedContent(joinerName)) .targetType(NotificationTargetType.PROJECT) .targetId(projectId) .build()); } /** - * 프로젝트 초대 알림 문구를 정책에 맞춰 생성한다. + * 프로젝트에 새 일정이 등록되었을 때 프로젝트 참여자에게 알림을 생성한다. */ @Transactional - public void createProjectInvitationNotification( - Long recipientId, + public void createScheduleCreatedNotifications( Long projectId, + Long scheduleId, String projectTitle, - String inviterName + String scheduleTitle, + String creatorName, + List recipientIds, + Long actorUserId ) { validateRequiredId(projectId); - validateRequiredId(recipientId); + validateRequiredId(scheduleId); + validateRequiredId(actorUserId); validateRequiredText(projectTitle); - validateRequiredText(inviterName); + validateRequiredText(scheduleTitle); + validateRequiredText(creatorName); - createNotification(NotificationCreateCommand.builder() - .recipientIds(List.of(recipientId)) + createNotifications(NotificationCreateCommand.builder() + .recipientIds(recipientIds) .projectId(projectId) - .type(NotificationType.PROJECT_INVITED) - .title(createProjectInvitationTitle(projectTitle)) - .content(createProjectInvitationContent(projectTitle, inviterName)) - .targetType(NotificationTargetType.PROJECT) - .targetId(projectId) + .type(NotificationType.SCHEDULE_CREATED) + .title(createScheduleCreatedTitle(projectTitle)) + .content(createScheduleCreatedContent(scheduleTitle, creatorName)) + .targetType(NotificationTargetType.SCHEDULE) + .targetId(scheduleId) + .excludeUserId(actorUserId) .build()); } @@ -320,7 +349,7 @@ public void createVideoFeedbackCommentedNotifications( * 새로운 지원자 발생 알림을 생성하거나 기존 미읽음 알림을 갱신한다. * 동일 공고 기준으로 그룹핑하므로 targetId는 recruitmentId를 사용한다. * - * @deprecated 알림 문구는 알림 도메인에서 관리하므로 recruitmentTitle과 applicantName을 받는 메서드를 사용한다. + * @deprecated 알림 문구는 알림 도메인에서 관리하므로 projectTitle, recruitmentTitle, applicantName을 받는 메서드를 사용한다. */ @Deprecated @Transactional @@ -349,18 +378,20 @@ public void createRecruitmentAppliedNotification( public void createRecruitmentAppliedNotification( Long recipientId, Long recruitmentId, + String projectTitle, String recruitmentTitle, String applicantName ) { validateRequiredId(recipientId); validateRequiredId(recruitmentId); + validateRequiredText(projectTitle); validateRequiredText(recruitmentTitle); validateRequiredText(applicantName); NotificationCreateCommand command = NotificationCreateCommand.builder() .recipientIds(List.of(recipientId)) .type(NotificationType.RECRUITMENT_APPLIED) - .title(createRecruitmentAppliedTitle(recruitmentTitle)) + .title(createRecruitmentAppliedTitle(projectTitle)) .content(createRecruitmentAppliedContent(recruitmentTitle, applicantName, 1)) .targetType(NotificationTargetType.RECRUITMENT) .targetId(recruitmentId) @@ -372,6 +403,88 @@ public void createRecruitmentAppliedNotification( ); } + /** + * 공고 프로젝트명을 알 수 없는 기존 호출부에서 사용하는 지원자 알림 생성 메서드다. + * 프로젝트명을 전달할 수 있는 경우에는 5개 인자 메서드를 사용한다. + */ + @Transactional + public void createRecruitmentAppliedNotification( + Long recipientId, + Long recruitmentId, + String recruitmentTitle, + String applicantName + ) { + createRecruitmentAppliedNotification( + recipientId, + recruitmentId, + recruitmentTitle, + recruitmentTitle, + applicantName + ); + } + + /** + * 프로젝트 공지가 등록되었을 때 프로젝트 참여자에게 알림을 생성한다. + */ + @Transactional + public void createNoticeCreatedNotifications( + Long projectId, + Long noticeId, + String projectTitle, + String noticeTitle, + String creatorName, + List recipientIds, + Long actorUserId + ) { + validateRequiredId(projectId); + validateRequiredId(noticeId); + validateRequiredId(actorUserId); + validateRequiredText(projectTitle); + validateRequiredText(noticeTitle); + validateRequiredText(creatorName); + + createNotifications(NotificationCreateCommand.builder() + .recipientIds(recipientIds) + .projectId(projectId) + .type(NotificationType.NOTICE_CREATED) + .title(createNoticeCreatedTitle(projectTitle)) + .content(createNoticeCreatedContent(noticeTitle, creatorName)) + .targetType(NotificationTargetType.NOTICE) + .targetId(noticeId) + .excludeUserId(actorUserId) + .build()); + } + + /** + * 프로젝트 파일 등록 알림을 생성한다. + */ + @Transactional + public void createFileUploadedNotifications( + Long projectId, + String projectTitle, + String fileName, + String uploaderName, + List recipientIds, + Long actorUserId + ) { + validateRequiredId(projectId); + validateRequiredId(actorUserId); + validateRequiredText(projectTitle); + validateRequiredText(fileName); + validateRequiredText(uploaderName); + + createNotifications(NotificationCreateCommand.builder() + .recipientIds(recipientIds) + .projectId(projectId) + .type(NotificationType.FILE_UPLOADED) + .title(createFileUploadedTitle(projectTitle)) + .content(createFileUploadedContent(fileName, uploaderName)) + .targetType(NotificationTargetType.PROJECT_FILE) + .targetId(projectId) + .excludeUserId(actorUserId) + .build()); + } + private void validateActiveUser(Long currentUserId) { if (!userRepository.existsByIdAndDeletedAtIsNull(currentUserId)) { throw new BaseException(CommonErrorCode.NOT_FOUND); @@ -555,20 +668,28 @@ private NotificationListResponse.NotificationSummary toSummary(Notification noti .build(); } - private String createScheduleAssignedContent(String scheduleTitle) { - return "'" + scheduleTitle + "' 일정 담당자로 지정되었습니다."; + private String createScheduleAssignedContent(String scheduleTitle, String assigneeName) { + return assigneeName + "님이 [" + scheduleTitle + "] 담당자로 지정되었어요"; } private String createScheduleAssignedTitle(String projectTitle) { return joinTitle(projectTitle, "일정 담당자 지정"); } - private String createProjectInvitationTitle(String projectTitle) { - return joinTitle(projectTitle, "프로젝트 초대"); + private String createProjectJoinedTitle(String projectTitle) { + return joinTitle(projectTitle, "합류"); + } + + private String createProjectJoinedContent(String joinerName) { + return joinerName + "님이 프로젝트에 합류했어요"; } - private String createProjectInvitationContent(String projectTitle, String inviterName) { - return inviterName + "님이 [" + projectTitle + "] 프로젝트에 초대했어요"; + private String createScheduleCreatedTitle(String projectTitle) { + return joinTitle(projectTitle, "새 일정"); + } + + private String createScheduleCreatedContent(String scheduleTitle, String creatorName) { + return creatorName + "님이 [" + scheduleTitle + "] 일정을 등록했어요"; } private String createVideoFeedbackCommentedTitle(String projectTitle) { @@ -587,8 +708,8 @@ private String createVideoFeedbackCommentedContent( return "[" + videoTitle + "]에 새로운 피드백 " + groupCount + "건이 등록되었어요"; } - private String createRecruitmentAppliedTitle(String recruitmentTitle) { - return joinTitle(recruitmentTitle, "새로운 지원자"); + private String createRecruitmentAppliedTitle(String projectTitle) { + return joinTitle(projectTitle, "새로운 지원자"); } private String createRecruitmentAppliedContent( @@ -603,10 +724,26 @@ private String createRecruitmentAppliedContent( return "[" + recruitmentTitle + "]에 새로운 지원자 " + groupCount + "명이 지원했어요"; } + private String createNoticeCreatedTitle(String projectTitle) { + return joinTitle(projectTitle, "새 공지"); + } + + private String createNoticeCreatedContent(String noticeTitle, String creatorName) { + return creatorName + "님이 새 공지를 등록했어요: " + noticeTitle; + } + + private String createFileUploadedTitle(String projectTitle) { + return joinTitle(projectTitle, "새 파일"); + } + + private String createFileUploadedContent(String fileName, String uploaderName) { + return uploaderName + "님이 [" + fileName + "] 파일을 등록했어요"; + } + private String createFallbackTitle(NotificationType type) { return switch (type) { case SCHEDULE_ASSIGNED -> "일정 담당자 지정"; - case PROJECT_INVITED -> "프로젝트 초대"; + case PROJECT_JOINED -> "합류"; case VIDEO_FEEDBACK_COMMENTED -> "새로운 피드백"; case RECRUITMENT_APPLIED -> "새로운 지원자"; case SCHEDULE_CREATED -> "새 일정"; 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/java/com/slatto/domain/video/dto/response/VideoResponse.java b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java index 0b7aabb4..7667b184 100644 --- a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java +++ b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java @@ -22,8 +22,6 @@ public record VideoDetailResDTO( String thumbnailUrl, @Schema(description = "영상 진행 상태", example = "IN_PROGRESS") String progressStatus, @Schema(description = "로그인한 사용자의 북마크 여부", example = "true") boolean bookmarked, - @Schema(description = "로그인한 사용자의 읽지 않은 피드백 개수", example = "0") - int unreadCommentCount, @Schema(description = "프로젝트 소개", example = "프로젝트 소개글") String description, @Schema(description = "영상 메모", example = "영상에 관련된 메모", nullable = true) String memo, @Schema(description = "프로젝트 태그", example = "[\"뮤직비디오\", \"단편\", \"외주\", \"연출\"]") @@ -31,10 +29,11 @@ public record VideoDetailResDTO( @Schema(description = "생성일", example = "2026-05-20T00:00:00") LocalDateTime createdAt, @Schema(description = "수정일", example = "2026-05-25T00:00:00") LocalDateTime updatedAt ) { - public static VideoDetailResDTO from(Video video, boolean bookmarked, List projectTags) { - // TODO: 피드백 도메인의 사용자별 읽지 않은 피드백 개수 조회 기능 연동 후 실제 값으로 교체 - int unreadCommentCount = 0; - + public static VideoDetailResDTO from( + Video video, + boolean bookmarked, + List projectTags + ) { return new VideoDetailResDTO( video.getId(), video.getProject().getId(), @@ -44,7 +43,6 @@ public static VideoDetailResDTO from(Video video, boolean bookmarked, List bookmarkedVideoIds = videoIds.isEmpty() ? Set.of() : Set.copyOf(videoBookmarkRepository.findBookmarkedVideoIdsByUserIdAndVideoIds(memberId, videoIds)); + Map unreadCommentCounts = notificationService.getUnreadVideoFeedbackCounts( + memberId, + videoIds + ); List items = currentPageVideos.stream() .map(video -> VideoItemResDTO.from( video, - bookmarkedVideoIds.contains(video.getId()) + bookmarkedVideoIds.contains(video.getId()), + unreadCommentCounts.getOrDefault(video.getId(), 0) > 0 )) .toList(); Long nextCursor = hasNext && !items.isEmpty() ? items.getLast().videoId() : null; 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/feedback/service/FeedbackActivityLogConnectionTest.java b/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java index 956a9485..cc072da1 100644 --- a/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java +++ b/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java @@ -17,6 +17,7 @@ import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.repository.UserRepository; import com.slatto.domain.video.entity.Video; +import com.slatto.domain.notification.service.NotificationService; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.BeforeEach; @@ -49,6 +50,7 @@ class FeedbackActivityLogConnectionTest { @Mock private TypedQuery