diff --git a/src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java b/src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java index 5cd4c001..9c73f9d2 100644 --- a/src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java +++ b/src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java @@ -7,6 +7,7 @@ import hanium.modic.backend.common.property.property.CloudFrontProperties; import hanium.modic.backend.common.property.property.CorsProperties; import hanium.modic.backend.common.property.property.EmailProperty; +import hanium.modic.backend.common.property.property.NotificationProperties; import hanium.modic.backend.common.property.property.RabbitMqProperties; import hanium.modic.backend.common.property.property.RedisProperty; import hanium.modic.backend.common.property.property.S3Properties; @@ -28,7 +29,8 @@ RabbitMqProperties.class, CloudFrontProperties.class, AiProperties.class, - VoteProperties.class + VoteProperties.class, + NotificationProperties.class }) public class PropertyConfig { } diff --git a/src/main/java/hanium/modic/backend/common/property/property/NotificationProperties.java b/src/main/java/hanium/modic/backend/common/property/property/NotificationProperties.java new file mode 100644 index 00000000..4484db82 --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/property/property/NotificationProperties.java @@ -0,0 +1,31 @@ +package hanium.modic.backend.common.property.property; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@ConfigurationProperties(prefix = "notification") +public class NotificationProperties { + + /** + * 읽은 알림을 얼마 동안 유지할지(분 단위) + */ + private long readRetentionMinutes = Duration.ofDays(30).toMinutes(); // 기본값: 30일 + + public long getReadRetentionMinutes() { + return readRetentionMinutes; + } + + public void setReadRetentionMinutes(long readRetentionMinutes) { + this.readRetentionMinutes = readRetentionMinutes; + } + + public Duration getReadRetentionDuration() { + return Duration.ofMinutes(readRetentionMinutes); + } +} diff --git a/src/main/java/hanium/modic/backend/common/scheduler/DailyScheduler.java b/src/main/java/hanium/modic/backend/common/scheduler/DailyScheduler.java new file mode 100644 index 00000000..bf1e12ec --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/scheduler/DailyScheduler.java @@ -0,0 +1,20 @@ +package hanium.modic.backend.common.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import hanium.modic.backend.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DailyScheduler { + + private final NotificationService notificationService; + + // 매일 자정(00:00:00)에 실행 + @Scheduled(cron = "0 0 0 * * *") + public void runEveryMidnight() { + notificationService.cleanupExpiredNotifications(); + } +} diff --git a/src/main/java/hanium/modic/backend/common/scheduler/config/SchedulingConfig.java b/src/main/java/hanium/modic/backend/common/scheduler/config/SchedulingConfig.java new file mode 100644 index 00000000..d2a08511 --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/scheduler/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package hanium.modic.backend.common.scheduler.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java b/src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java index 53294f41..4a2f95ee 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java @@ -7,7 +7,12 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.domain.notification.dto.NotificationPayload; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.service.NotificationService; +import hanium.modic.backend.domain.user.entity.UserEntity; +import hanium.modic.backend.domain.user.repository.UserEntityRepository; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.ai.aiChat.entity.AiChatRoomEntity; import hanium.modic.backend.domain.ai.aiChat.repository.AiChatRoomRepository; import hanium.modic.backend.domain.post.entity.PostEntity; @@ -25,7 +30,9 @@ public class AiImagePermissionService { private final TicketService ticketService; private final PostEntityRepository postRepository; private final AiChatRoomRepository aiChatRoomRepository; + private final NotificationService notificationService; private final LockManager lockManager; + private final UserEntityRepository userEntityRepository; private final int AI_IMAGE_PERMISSION_COUNT = 20; // 구매 시 제공되는 이미지 생성 횟수 @@ -42,6 +49,19 @@ public void buyAiImagePermissionByCoin(Long userId, Long postId) { // 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파 userCoinService.consumeCoin(userId, post.getNonCommercialPrice()); + + // 4) 알림 + UserEntity user = userEntityRepository.findById(userId) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + notificationService.createNotification( + post.getUserId(), + NotificationType.POST_PURCHASED_BY_COIN, + NotificationPayload.builder(userId, user.getName(), user.getEmail()) + .postId(post.getId()) + .postTitle(post.getTitle()) + .amount(post.getNonCommercialPrice()) + .build() + ); } // 티켓으로 AI 이미지 생성권 구매 @@ -56,6 +76,19 @@ public void buyAiImagePermissionByTicket(final Long userId, final Long postId) { // 3) 티켓 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파 ticketService.useTicket(userId, post.getTicketPrice()); + + // 4) 알림 + UserEntity user = userEntityRepository.findById(userId) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + notificationService.createNotification( + post.getUserId(), + NotificationType.POST_PURCHASED_BY_TICKET, + NotificationPayload.builder(userId, user.getName(), user.getEmail()) + .postId(post.getId()) + .postTitle(post.getTitle()) + .amount(post.getTicketPrice()) + .build() + ); } // 이미지 생성권 소모 diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedDlqListener.java b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedDlqListener.java index 1672b71e..0f237e79 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedDlqListener.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedDlqListener.java @@ -1,6 +1,6 @@ package hanium.modic.backend.domain.ai.aiServer.listener; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import java.util.Optional; diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java index a89e93d5..d1c71e78 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java @@ -1,6 +1,6 @@ package hanium.modic.backend.domain.ai.aiServer.listener; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import java.util.Optional; diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java index 19469404..27c4662d 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java @@ -1,6 +1,6 @@ package hanium.modic.backend.domain.ai.aiServer.listener; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import java.util.Optional; diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiResponseSseService.java b/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiResponseSseService.java index f1a95046..cb1a6b08 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiResponseSseService.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiResponseSseService.java @@ -8,7 +8,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import hanium.modic.backend.common.error.exception.AppException; -import hanium.modic.backend.common.sse.service.EmitterService; +import hanium.modic.backend.infra.sse.service.EmitterService; import hanium.modic.backend.domain.ai.aiChat.entity.AiChatMessageEntity; import hanium.modic.backend.domain.ai.aiChat.repository.AiChatMessageRepository; import hanium.modic.backend.domain.ai.aiServer.enums.SenderType; diff --git a/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java b/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java index 85fbfc0e..709c96de 100644 --- a/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java +++ b/src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java @@ -12,7 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import hanium.modic.backend.common.amqp.service.MessageQueueService; +import hanium.modic.backend.infra.amqp.service.MessageQueueService; import hanium.modic.backend.common.error.ErrorCode; import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.domain.ai.aiChat.entity.AiChatMessageEntity; diff --git a/src/main/java/hanium/modic/backend/domain/follow/service/FollowService.java b/src/main/java/hanium/modic/backend/domain/follow/service/FollowService.java index c1b18c72..42665ed7 100644 --- a/src/main/java/hanium/modic/backend/domain/follow/service/FollowService.java +++ b/src/main/java/hanium/modic/backend/domain/follow/service/FollowService.java @@ -15,9 +15,11 @@ import hanium.modic.backend.domain.follow.dto.FollowerWithStatus; import hanium.modic.backend.domain.follow.dto.FollowingWithStatus; import hanium.modic.backend.domain.follow.repository.FollowEntityRepository; +import hanium.modic.backend.domain.notification.dto.NotificationPayload; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.service.NotificationService; import hanium.modic.backend.domain.user.entity.UserEntity; import hanium.modic.backend.domain.user.repository.UserEntityRepository; -import hanium.modic.backend.domain.user.repository.UserImageEntityRepository; import hanium.modic.backend.domain.user.service.UserImageService; import hanium.modic.backend.web.follow.dto.response.GetFollowersResponse; import hanium.modic.backend.web.follow.dto.response.GetFollowersWithStatusResponse; @@ -28,10 +30,15 @@ @Service @RequiredArgsConstructor public class FollowService { + // 팔로우 관련 private final FollowEntityRepository followRepository; + + // 유저 관련 private final UserEntityRepository userRepository; private final UserImageService userImageService; - private final UserImageEntityRepository userImageRepository; + + // 알림 관련 + private final NotificationService notificationService; // 팔로우 또는 언팔로우 처리 @Transactional @@ -51,6 +58,14 @@ public void followOrUnfollow( // 팔로우 요청 및 기존 팔로우 존재 여부에 따라 팔로우, 언팔로우 처리 if (type == FOLLOW) { followRepository.insertFollowIfExist(me.getId(), target.getId()); + + // 알림 생성 + notificationService.createNotification( + targetId, + NotificationType.FOLLOWED, + NotificationPayload.builder(me.getId(), me.getName(), me.getEmail()) + .build() + ); } else if (type == UNFOLLOW) { followRepository.deleteByMyIdAndFollowingId(me.getId(), targetId); } diff --git a/src/main/java/hanium/modic/backend/domain/notification/dto/GetNotificationsResponse.java b/src/main/java/hanium/modic/backend/domain/notification/dto/GetNotificationsResponse.java new file mode 100644 index 00000000..efedeaff --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/dto/GetNotificationsResponse.java @@ -0,0 +1,30 @@ +package hanium.modic.backend.domain.notification.dto; + +import java.time.LocalDateTime; + +import hanium.modic.backend.domain.notification.entity.NotificationEntity; +import hanium.modic.backend.domain.notification.enums.NotificationStatus; +import hanium.modic.backend.domain.notification.enums.NotificationType; + +public record GetNotificationsResponse( + Long notificationId, + NotificationType type, + NotificationStatus status, + String title, + String body, + Long postId, + LocalDateTime createdAt +) { + + public static GetNotificationsResponse of(NotificationEntity entity, NotificationPayload payload) { + return new GetNotificationsResponse( + entity.getId(), + entity.getType(), + entity.getStatus(), + entity.getTitle(), + entity.getBody(), + payload.postId(), + entity.getCreateAt() + ); + } +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/dto/GetUnreadCountResponse.java b/src/main/java/hanium/modic/backend/domain/notification/dto/GetUnreadCountResponse.java new file mode 100644 index 00000000..46b4ae4f --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/dto/GetUnreadCountResponse.java @@ -0,0 +1,6 @@ +package hanium.modic.backend.domain.notification.dto; + +public record GetUnreadCountResponse( + long unreadCount +) { +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/dto/NotificationPayload.java b/src/main/java/hanium/modic/backend/domain/notification/dto/NotificationPayload.java new file mode 100644 index 00000000..b7191487 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/dto/NotificationPayload.java @@ -0,0 +1,71 @@ +package hanium.modic.backend.domain.notification.dto; + +public record NotificationPayload( + Long senderId, + String senderNickname, + String senderEmail, // 추가됨 + Long postId, + String postTitle, + Long amount, + String reviewContent +) { + + public static NotificationPayload empty() { + return new NotificationPayload( + null, null, null, null, null, null, null + ); + } + + public static Builder builder(Long senderId, String senderNickname, String senderEmail) { + return new Builder(senderId, senderNickname, senderEmail); + } + + public static class Builder { + private final Long senderId; + private final String senderNickname; + private final String senderEmail; + + private Long postId; + private String postTitle; + private Long amount; + private String reviewContent; + + public Builder(Long senderId, String senderNickname, String senderEmail) { + this.senderId = senderId; + this.senderNickname = senderNickname; + this.senderEmail = senderEmail; + } + + public Builder postId(Long postId) { + this.postId = postId; + return this; + } + + public Builder postTitle(String postTitle) { + this.postTitle = postTitle; + return this; + } + + public Builder amount(Long amount) { + this.amount = amount; + return this; + } + + public Builder reviewContent(String reviewContent) { + this.reviewContent = reviewContent; + return this; + } + + public NotificationPayload build() { + return new NotificationPayload( + senderId, + senderNickname, + senderEmail, + postId, + postTitle, + amount, + reviewContent + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/notification/entity/NotificationEntity.java b/src/main/java/hanium/modic/backend/domain/notification/entity/NotificationEntity.java new file mode 100644 index 00000000..8acbe789 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/entity/NotificationEntity.java @@ -0,0 +1,96 @@ +package hanium.modic.backend.domain.notification.entity; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.Duration; +import java.time.LocalDateTime; + +import hanium.modic.backend.common.entity.BaseEntity; +import hanium.modic.backend.domain.notification.enums.NotificationStatus; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "notification") +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class NotificationEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(name = "recipient_user_id", nullable = false) + private Long recipientUserId; + + @Column(name = "type", nullable = false) + @Enumerated(EnumType.STRING) + private NotificationType type; + + @Column(name = "status", nullable = false) + @Enumerated(EnumType.STRING) + private NotificationStatus status; + + @Column(name = "title", nullable = false, length = 150) + private String title; + + @Column(name = "body", nullable = false, length = 500) + private String body; + + @Column(name = "payload", columnDefinition = "TEXT") + private String payload; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Builder + public NotificationEntity( + Long recipientUserId, + NotificationType type, + NotificationStatus status, + String title, + String body, + String payload, + LocalDateTime readAt, + LocalDateTime expiresAt + ) { + this.recipientUserId = recipientUserId; + this.type = type; + this.status = status == null ? NotificationStatus.UNREAD : status; + this.title = title; + this.body = body; + this.payload = payload; + this.readAt = readAt; + this.expiresAt = expiresAt; + } + + public boolean isUnread() { + return NotificationStatus.UNREAD.equals(this.status); + } + + public void markAsRead(LocalDateTime readAt, Duration retention) { + if (!isUnread()) { + return; + } + this.status = NotificationStatus.READ; + this.readAt = readAt; + this.expiresAt = retention == null ? null : readAt.plus(retention); + } + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && !expiresAt.isAfter(now); + } +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationStatus.java b/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationStatus.java new file mode 100644 index 00000000..96a41bac --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationStatus.java @@ -0,0 +1,6 @@ +package hanium.modic.backend.domain.notification.enums; + +public enum NotificationStatus { + UNREAD, + READ +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationType.java b/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationType.java new file mode 100644 index 00000000..968ed1d0 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/enums/NotificationType.java @@ -0,0 +1,97 @@ +package hanium.modic.backend.domain.notification.enums; + +import hanium.modic.backend.domain.notification.dto.NotificationPayload; + +public enum NotificationType { + + COIN_RECEIVED { + @Override + public String generateTitle(NotificationPayload payload) { + return "코인이 도착했습니다!"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + "님이 " + payload.amount() + " 코인을 보내셨습니다."; + } + }, + + POST_PURCHASED_BY_COIN { + @Override + public String generateTitle(NotificationPayload payload) { + return "'" + payload.postTitle() + "' 게시글이 구매되었습니다"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + + "님이 " + + payload.amount() + + " 코인으로 게시글을 구매했습니다."; + } + }, + + POST_PURCHASED_BY_TICKET { + @Override + public String generateTitle(NotificationPayload payload) { + return "'" + payload.postTitle() + "' 게시글이 구매되었습니다"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + + "님이 " + + payload.amount() + + " 티켓으로 게시글을 구매했습니다."; + } + }, + + POST_REVIEWED { + @Override + public String generateTitle(NotificationPayload payload) { + return "'" + payload.postTitle() + "' 게시글에 새로운 후기가 달렸습니다"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + "님의 후기: \"" + payload.reviewContent() + "\""; + } + }, + + FOLLOWED { + @Override + public String generateTitle(NotificationPayload payload) { + return sender(payload) + "님이 회원님을 팔로우했습니다"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + "님이 회원님을 새롭게 팔로우했어요."; + } + }, + + DERIVED_POST_CREATED { + @Override + public String generateTitle(NotificationPayload payload) { + return "'" + payload.postTitle() + "' 게시글로 파생 포스트가 생성되었습니다"; + } + + @Override + public String generateBody(NotificationPayload payload) { + return sender(payload) + + "님이 '" + payload.postTitle() + "'을(를) 기반으로 파생 포스트를 만들었습니다."; + } + }; + + public abstract String generateTitle(NotificationPayload payload); + + public abstract String generateBody(NotificationPayload payload); + + // ★ 닉네임(이메일) 포맷 메서드 + protected static String sender(NotificationPayload payload) { + if (payload.senderEmail() == null) { + return payload.senderNickname(); + } + return payload.senderNickname() + "(" + payload.senderEmail() + ")"; + } +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/repository/NotificationRepository.java b/src/main/java/hanium/modic/backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..0c5c91a1 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,44 @@ +package hanium.modic.backend.domain.notification.repository; + +import java.time.LocalDateTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import hanium.modic.backend.domain.notification.entity.NotificationEntity; +import hanium.modic.backend.domain.notification.enums.NotificationStatus; + +public interface NotificationRepository extends JpaRepository { + + @Query(""" + SELECT n + FROM NotificationEntity n + WHERE n.recipientUserId = :recipientId + AND (n.expiresAt IS NULL OR n.expiresAt > :now) + """) + Page findActiveNotifications( + @Param("recipientId") Long recipientId, + @Param("now") LocalDateTime now, + Pageable pageable + ); + + @Query(""" + SELECT COUNT(n) + FROM NotificationEntity n + WHERE n.recipientUserId = :recipientId + AND n.status = :status + AND (n.expiresAt IS NULL OR n.expiresAt > :now) + """) + long countActiveByRecipientIdAndStatus( + @Param("recipientId") Long recipientId, + @Param("status") NotificationStatus status, + @Param("now") LocalDateTime now + ); + + @Modifying(clearAutomatically = true) + void deleteByStatusAndExpiresAtBefore(NotificationStatus status, LocalDateTime now); +} diff --git a/src/main/java/hanium/modic/backend/domain/notification/service/NotificationService.java b/src/main/java/hanium/modic/backend/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..f4d40230 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/notification/service/NotificationService.java @@ -0,0 +1,142 @@ +package hanium.modic.backend.domain.notification.service; + +import static org.springframework.data.domain.Sort.Direction.DESC; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import hanium.modic.backend.common.property.property.NotificationProperties; +import hanium.modic.backend.domain.notification.dto.GetNotificationsResponse; +import hanium.modic.backend.domain.notification.dto.NotificationPayload; +import hanium.modic.backend.domain.notification.dto.GetUnreadCountResponse; +import hanium.modic.backend.domain.notification.entity.NotificationEntity; +import hanium.modic.backend.domain.notification.enums.NotificationStatus; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private static final Sort CREATED_AT_DESC = Sort.by(DESC, "createAt"); + + private final NotificationRepository notificationRepository; + private final NotificationProperties notificationProperties; + private final ObjectMapper objectMapper; + + // 알림 목록 조회 및 읽음 처리 + @Transactional + public Page getNotifications(Long recipientId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, CREATED_AT_DESC); // createdAt 기준 내림차순 + LocalDateTime snapshot = LocalDateTime.now(); + + // 알림 조회(읽은 것, 읽지 않은 것 모두) + Page notifications = notificationRepository.findActiveNotifications( + recipientId, + snapshot, + pageable + ); + Page responses = notifications.map(notification -> + GetNotificationsResponse.of(notification, deserializePayload(notification.getPayload())) + ); + + // 알림 읽음 처리 + markAsRead(notifications.getContent(), snapshot); + + return responses; + } + + // 안읽은 알림 수 조회 + @Transactional + public GetUnreadCountResponse getUnreadCount(Long recipientId) { + LocalDateTime now = LocalDateTime.now(); + long unreadCount = notificationRepository.countActiveByRecipientIdAndStatus( + recipientId, + NotificationStatus.UNREAD, + now + ); + + return new GetUnreadCountResponse(unreadCount); + } + + // 알림을 읽음 처리 + // 읽음처리된 알림은 일정 기간 후 만료되어 삭제됨 + // 이를 스케줄러에서 처리함 + private void markAsRead(List notifications, LocalDateTime readAt) { + notifications.stream() + .filter(NotificationEntity::isUnread) + .forEach(notification -> notification.markAsRead(readAt, notificationProperties.getReadRetentionDuration())); + } + + // 만료된 읽음 알림 정리 + @Transactional + public void cleanupExpiredNotifications() { + LocalDateTime now = LocalDateTime.now(); + notificationRepository.deleteByStatusAndExpiresAtBefore(NotificationStatus.READ, now); + } + + // 알림 저장 + @Transactional + public void createNotification( + Long recipientUserId, + NotificationType type, + NotificationPayload payload + ) { + String title = type.generateTitle(payload); + String body = type.generateBody(payload); + + // payload JSON 직렬화 + String payloadJson = serializePayload(payload); + + NotificationEntity entity = NotificationEntity.builder() + .recipientUserId(recipientUserId) + .type(type) + .status(NotificationStatus.UNREAD) + .title(title) + .body(body) + .payload(payloadJson) + .readAt(null) + .expiresAt(null) + .build(); + + notificationRepository.save(entity); + } + + // 알림 페이로드 직렬화 + // postId 등 RDB가 지원하지 않는 복합 구조를 JSON 문자열로 저장하기 위함 + private String serializePayload(NotificationPayload payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize notification payload", e); + } + } + + // 알림 페이로드 역직렬화 + // RDB에 저장된 JSON 문자열을 NotificationPayload 객체로 변환 + private NotificationPayload deserializePayload(String payload) { + if (payload == null || payload.isBlank()) { + return NotificationPayload.empty(); + } + + try { + return objectMapper.readValue(payload, NotificationPayload.class); + } catch (JsonProcessingException exception) { + log.warn("Failed to deserialize notification payload: {}", payload, exception); + return NotificationPayload.empty(); + } + } +} diff --git a/src/main/java/hanium/modic/backend/domain/postLike/service/PostLikeService.java b/src/main/java/hanium/modic/backend/domain/postLike/service/PostLikeService.java index a4dc6b21..21dddeeb 100644 --- a/src/main/java/hanium/modic/backend/domain/postLike/service/PostLikeService.java +++ b/src/main/java/hanium/modic/backend/domain/postLike/service/PostLikeService.java @@ -12,7 +12,7 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.post.entity.PostEntity; import hanium.modic.backend.domain.post.repository.PostEntityRepository; import hanium.modic.backend.domain.postLike.entity.PostLikeEntity; diff --git a/src/main/java/hanium/modic/backend/domain/postReview/service/PostReviewService.java b/src/main/java/hanium/modic/backend/domain/postReview/service/PostReviewService.java index ed647ee8..89c25d1a 100644 --- a/src/main/java/hanium/modic/backend/domain/postReview/service/PostReviewService.java +++ b/src/main/java/hanium/modic/backend/domain/postReview/service/PostReviewService.java @@ -15,6 +15,9 @@ import org.springframework.transaction.annotation.Transactional; import hanium.modic.backend.common.error.exception.AppException; +import hanium.modic.backend.domain.notification.dto.NotificationPayload; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.service.NotificationService; import hanium.modic.backend.domain.post.entity.PostEntity; import hanium.modic.backend.domain.post.repository.PostEntityRepository; import hanium.modic.backend.domain.postReview.entity.PostReviewEntity; @@ -31,14 +34,22 @@ @RequiredArgsConstructor public class PostReviewService { + // 포스트 리뷰 관련 private final PostReviewRepository postReviewRepository; private final PostReviewImageService postReviewImageService; private final PostReviewImageRepository postReviewImageRepository; + private final PostReviewAuthorizationService postReviewAuthorizationService; + + // 포스트 관련 private final PostEntityRepository postEntityRepository; + + // 유저 관련 private final UserEntityRepository userEntityRepository; - private final PostReviewAuthorizationService postReviewAuthorizationService; private final UserImageService userImageService; + // 알림 관련 + private final NotificationService notificationService; + // 포스트 리뷰 생성 @Transactional public void createPostReview( @@ -64,6 +75,17 @@ public void createPostReview( .forEach(image -> { image.updatePostReview(postReview); }); + + // 알람 + notificationService.createNotification( + post.getUserId(), + NotificationType.POST_REVIEWED, + NotificationPayload.builder(user.getId(), user.getName(), user.getEmail()) + .postId(postId) + .postTitle(post.getTitle()) + .reviewContent(postReview.getDescription()) + .build() + ); } // 포스트 리뷰 삭제 diff --git a/src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java b/src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java index 0167b1c0..9e4310d7 100644 --- a/src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java +++ b/src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java @@ -7,7 +7,7 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.common.property.property.VoteProperties; import hanium.modic.backend.domain.ticket.entity.TicketEntity; import hanium.modic.backend.domain.ticket.repository.TicketRepository; diff --git a/src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java b/src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java index bac7d456..76656763 100644 --- a/src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java +++ b/src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java @@ -8,7 +8,10 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.domain.notification.dto.NotificationPayload; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.service.NotificationService; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.user.entity.UserEntity; import hanium.modic.backend.domain.user.repository.UserEntityRepository; import hanium.modic.backend.web.user.dto.response.GetCoinBalanceResponse; @@ -22,6 +25,8 @@ public class UserCoinService { private final LockManager lockManager; + private final NotificationService notificationService; + // 코인 잔액 조회 public GetCoinBalanceResponse getCoinBalance(final Long userId) { @@ -44,10 +49,15 @@ public void chargeCoin(final long userId, final long coin) { // 코인 양도 public void transferCoin(final long fromUserId, final long toUserId, long coin) throws AppException { + // 자기 자신에게 양도 불가 if (fromUserId == toUserId) { throw new AppException(USER_COIN_TRANSFER_SAME_USER_EXCEPTION); } + // 받는 사람 존재 확인 + UserEntity toUser = userEntityRepository.findById(toUserId) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + try { lockManager.multipleUserLock(List.of(fromUserId, toUserId), () -> { // 출금 @@ -58,6 +68,15 @@ public void transferCoin(final long fromUserId, final long toUserId, long coin) } catch (LockException e) { throw new AppException(USER_COIN_TRANSFER_FAIL_EXCEPTION); } + + // 알림 + notificationService.createNotification( + toUserId, + NotificationType.COIN_RECEIVED, + NotificationPayload.builder(fromUserId, toUser.getName(), toUser.getEmail()) + .amount(coin) + .build() + ); } // 코인 소비 diff --git a/src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java b/src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java index 6fbaaef6..925103ad 100644 --- a/src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java +++ b/src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java @@ -4,7 +4,7 @@ import hanium.modic.backend.common.error.exception.LockException; import hanium.modic.backend.common.property.property.VoteProperties; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.user.entity.UserVoteStreak; import hanium.modic.backend.domain.user.repository.UserVoteStreakRepository; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/hanium/modic/backend/domain/vote/listener/SimilarityCheckListener.java b/src/main/java/hanium/modic/backend/domain/vote/listener/SimilarityCheckListener.java index 475ee683..dd78f401 100644 --- a/src/main/java/hanium/modic/backend/domain/vote/listener/SimilarityCheckListener.java +++ b/src/main/java/hanium/modic/backend/domain/vote/listener/SimilarityCheckListener.java @@ -1,7 +1,7 @@ package hanium.modic.backend.domain.vote.listener; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; import static hanium.modic.backend.domain.vote.enums.VoteStatus.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import java.util.Optional; diff --git a/src/main/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestService.java b/src/main/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestService.java index 7ff1fce7..23841503 100644 --- a/src/main/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestService.java +++ b/src/main/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestService.java @@ -1,6 +1,6 @@ package hanium.modic.backend.domain.vote.service; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import java.util.Optional; @@ -11,7 +11,6 @@ import hanium.modic.backend.domain.vote.dto.SimilarityCheckRequestDto; import hanium.modic.backend.domain.vote.entity.SimilarityVoteEntity; -import hanium.modic.backend.domain.vote.enums.VoteStatus; import hanium.modic.backend.domain.vote.repository.SimilarityVoteRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardService.java b/src/main/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardService.java index e4e00431..65f97357 100644 --- a/src/main/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardService.java +++ b/src/main/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardService.java @@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import hanium.modic.backend.common.error.exception.AppException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.common.error.exception.LockException; import hanium.modic.backend.domain.ticket.service.TicketService; import hanium.modic.backend.domain.vote.entity.SimilarityVoteResultEntity; diff --git a/src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java b/src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java index 8dc4cb44..31ad86e2 100644 --- a/src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java +++ b/src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java @@ -11,7 +11,7 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; import hanium.modic.backend.common.property.property.VoteProperties; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.user.service.UserVoteStreakService; import hanium.modic.backend.domain.vote.dto.VoteRewardResult; import hanium.modic.backend.domain.vote.entity.SimilarityVoteEntity; diff --git a/src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java b/src/main/java/hanium/modic/backend/infra/amqp/config/RabbitMqConfig.java similarity index 99% rename from src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java rename to src/main/java/hanium/modic/backend/infra/amqp/config/RabbitMqConfig.java index 513436aa..c27fc94a 100644 --- a/src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java +++ b/src/main/java/hanium/modic/backend/infra/amqp/config/RabbitMqConfig.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.amqp.config; +package hanium.modic.backend.infra.amqp.config; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java b/src/main/java/hanium/modic/backend/infra/amqp/service/MessageQueueService.java similarity index 80% rename from src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java rename to src/main/java/hanium/modic/backend/infra/amqp/service/MessageQueueService.java index 1e252517..ce1cf8a3 100644 --- a/src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java +++ b/src/main/java/hanium/modic/backend/infra/amqp/service/MessageQueueService.java @@ -1,6 +1,6 @@ -package hanium.modic.backend.common.amqp.service; +package hanium.modic.backend.infra.amqp.service; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Service; diff --git a/src/main/java/hanium/modic/backend/common/redis/config/RedisConfig.java b/src/main/java/hanium/modic/backend/infra/redis/config/RedisConfig.java similarity index 96% rename from src/main/java/hanium/modic/backend/common/redis/config/RedisConfig.java rename to src/main/java/hanium/modic/backend/infra/redis/config/RedisConfig.java index 5a5f625f..b7cba988 100644 --- a/src/main/java/hanium/modic/backend/common/redis/config/RedisConfig.java +++ b/src/main/java/hanium/modic/backend/infra/redis/config/RedisConfig.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.config; +package hanium.modic.backend.infra.redis.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/hanium/modic/backend/common/redis/config/RedissonConfig.java b/src/main/java/hanium/modic/backend/infra/redis/config/RedissonConfig.java similarity index 93% rename from src/main/java/hanium/modic/backend/common/redis/config/RedissonConfig.java rename to src/main/java/hanium/modic/backend/infra/redis/config/RedissonConfig.java index 17403985..e36c42e9 100644 --- a/src/main/java/hanium/modic/backend/common/redis/config/RedissonConfig.java +++ b/src/main/java/hanium/modic/backend/infra/redis/config/RedissonConfig.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.config; +package hanium.modic.backend.infra.redis.config; import org.redisson.Redisson; import org.redisson.api.RedissonClient; diff --git a/src/main/java/hanium/modic/backend/common/redis/distributedLock/AopForTransaction.java b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/AopForTransaction.java similarity index 85% rename from src/main/java/hanium/modic/backend/common/redis/distributedLock/AopForTransaction.java rename to src/main/java/hanium/modic/backend/infra/redis/distributedLock/AopForTransaction.java index 65ffd768..2436649b 100644 --- a/src/main/java/hanium/modic/backend/common/redis/distributedLock/AopForTransaction.java +++ b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/AopForTransaction.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.distributedLock; +package hanium.modic.backend.infra.redis.distributedLock; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; diff --git a/src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/DistributionLockExecutor.java similarity index 95% rename from src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java rename to src/main/java/hanium/modic/backend/infra/redis/distributedLock/DistributionLockExecutor.java index f8bf7f51..f55844d9 100644 --- a/src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java +++ b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/DistributionLockExecutor.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.distributedLock; +package hanium.modic.backend.infra.redis.distributedLock; import java.util.Collection; import java.util.List; @@ -10,8 +10,6 @@ import org.springframework.stereotype.Component; import hanium.modic.backend.common.error.exception.LockException; -import lombok.Builder; -import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockManager.java similarity index 97% rename from src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java rename to src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockManager.java index 7b9857d5..a172b124 100644 --- a/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java +++ b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockManager.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.distributedLock; +package hanium.modic.backend.infra.redis.distributedLock; import java.util.List; import java.util.concurrent.TimeUnit; diff --git a/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockOptions.java similarity index 86% rename from src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java rename to src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockOptions.java index 91b7074b..f5dc7c55 100644 --- a/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java +++ b/src/main/java/hanium/modic/backend/infra/redis/distributedLock/LockOptions.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.redis.distributedLock; +package hanium.modic.backend.infra.redis.distributedLock; import java.util.concurrent.TimeUnit; diff --git a/src/main/java/hanium/modic/backend/common/sse/service/EmitterService.java b/src/main/java/hanium/modic/backend/infra/sse/service/EmitterService.java similarity index 96% rename from src/main/java/hanium/modic/backend/common/sse/service/EmitterService.java rename to src/main/java/hanium/modic/backend/infra/sse/service/EmitterService.java index 45ed7460..7e28395f 100644 --- a/src/main/java/hanium/modic/backend/common/sse/service/EmitterService.java +++ b/src/main/java/hanium/modic/backend/infra/sse/service/EmitterService.java @@ -1,4 +1,4 @@ -package hanium.modic.backend.common.sse.service; +package hanium.modic.backend.infra.sse.service; import java.io.IOException; import java.util.Map; diff --git a/src/main/java/hanium/modic/backend/web/notification/controller/NotificationController.java b/src/main/java/hanium/modic/backend/web/notification/controller/NotificationController.java new file mode 100644 index 00000000..2381c476 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/notification/controller/NotificationController.java @@ -0,0 +1,88 @@ +package hanium.modic.backend.web.notification.controller; + +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import hanium.modic.backend.common.annotation.user.CurrentUser; +import hanium.modic.backend.common.response.AppResponse; +import hanium.modic.backend.common.response.PageResponse; +import hanium.modic.backend.domain.notification.dto.GetNotificationsResponse; +import hanium.modic.backend.domain.notification.dto.GetUnreadCountResponse; +import hanium.modic.backend.domain.notification.service.NotificationService; +import hanium.modic.backend.domain.user.entity.UserEntity; +import hanium.modic.backend.web.notification.dto.response.NotificationUnreadCountResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + + +@Tag(name = "알림", description = "알림 관련 API") +@RestController +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@RequestMapping("/api/notifications") +@Validated +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/unread-count") + @Operation( + summary = "안 읽은 알림 수 조회", + description = "클라이언트 배지 숫자를 갱신하기 위한 전용 API", + responses = { + @ApiResponse(responseCode = "200", description = "안 읽은 알림 수 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 필요[C-003]") + } + ) + public ResponseEntity> getUnreadCount(@CurrentUser UserEntity user) { + GetUnreadCountResponse unreadCount = notificationService.getUnreadCount(user.getId()); + return ResponseEntity.ok(AppResponse.ok(NotificationUnreadCountResponse.from(unreadCount))); + } + + @GetMapping + @Operation( + summary = "알림 목록 조회", + description = """ + 페이지네이션 방식으로 알림을 조회하며, onlyUnread=true 시 읽지 않은 항목만 반환합니다.
+ 알림을 조회하는 동시에 해당 알림들을 모두 읽음 처리합니다.
+ + status값으로는 다음과 같은 값이 올 수 있습니다.
+
    +
  • READ: 읽음
  • +
  • UNREAD: 읽지 않음
  • +
+ + type값으로는 다음과 같은 값이 올 수 있습니다.
+
    +
  • COIN_RECEIVED: 코인 전송
  • +
  • POST_PURCHASED_BY_COIN: 코인으로 포스트 구매
  • +
  • POST_PURCHASED_BY_TICKET: 티켓으로 포스트 구매
  • +
  • POST_REVIEWED: 포스트 후기 작성
  • +
  • FOLLOWED: 나를 팔로워함
  • +
  • DERIVED_POST_CREATED: 파생포스트 생성됨
  • +
+ + """, + responses = { + @ApiResponse(responseCode = "200", description = "알림 목록 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 필요[C-003]") + } + ) + public ResponseEntity>> getNotifications( + @CurrentUser UserEntity user, + @RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page, + @RequestParam(defaultValue = "20") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") @Max(value = 50, message = "페이지 크기는 50 이하여야 합니다.") int size + ) { + Page responses = notificationService.getNotifications(user.getId(), page, size); + return ResponseEntity.ok(AppResponse.ok(PageResponse.of(responses))); + } +} diff --git a/src/main/java/hanium/modic/backend/web/notification/dto/response/NotificationUnreadCountResponse.java b/src/main/java/hanium/modic/backend/web/notification/dto/response/NotificationUnreadCountResponse.java new file mode 100644 index 00000000..0b213e86 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/notification/dto/response/NotificationUnreadCountResponse.java @@ -0,0 +1,11 @@ +package hanium.modic.backend.web.notification.dto.response; + +import hanium.modic.backend.domain.notification.dto.GetUnreadCountResponse; + +public record NotificationUnreadCountResponse( + long unreadCount +) { + public static NotificationUnreadCountResponse from(GetUnreadCountResponse unreadCount) { + return new NotificationUnreadCountResponse(unreadCount.unreadCount()); + } +} diff --git a/src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java b/src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java index ceb45824..157c48f8 100644 --- a/src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java @@ -16,7 +16,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import hanium.modic.backend.common.error.exception.AppException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.domain.notification.enums.NotificationType; +import hanium.modic.backend.domain.notification.service.NotificationService; +import hanium.modic.backend.domain.user.repository.UserEntityRepository; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.ai.aiChat.entity.AiChatRoomEntity; import hanium.modic.backend.domain.ai.aiChat.repository.AiChatRoomRepository; import hanium.modic.backend.domain.ai.aiChat.service.AiImagePermissionService; @@ -49,6 +52,12 @@ class AiImagePermissionServiceTest { @Mock private LockManager lockManager; + @Mock + private NotificationService notificationService; + + @Mock + private UserEntityRepository userEntityRepository; + @Test @DisplayName("코인으로 AI 이미지 생성권 구매 - 성공") void buyAiImagePermissionByCoin_Success() { @@ -59,6 +68,8 @@ void buyAiImagePermissionByCoin_Success() { when(postRepository.findById(anyLong())).thenReturn(Optional.of(testPost)); when(aiChatRoomRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt())) .thenReturn(1); + when(userEntityRepository.findById(anyLong())).thenReturn(Optional.of(testUser)); + doNothing().when(notificationService).createNotification(anyLong(), any(), any()); // when aiImagePermissionService.buyAiImagePermissionByCoin(testUser.getId(), testPost.getId()); @@ -123,6 +134,8 @@ void buyAiImagePermissionByTicket_Success() { when(postRepository.findById(anyLong())).thenReturn(Optional.of(testPost)); when(aiChatRoomRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt())) .thenReturn(1); + when(userEntityRepository.findById(anyLong())).thenReturn(Optional.of(testUser)); + doNothing().when(notificationService).createNotification(anyLong(), any(), any()); // when aiImagePermissionService.buyAiImagePermissionByTicket(testUser.getId(), testPost.getId()); diff --git a/src/test/java/hanium/modic/backend/domain/follow/service/FollowMockingServiceTest.java b/src/test/java/hanium/modic/backend/domain/follow/service/FollowMockingServiceTest.java index 18fb0adf..6c4715be 100644 --- a/src/test/java/hanium/modic/backend/domain/follow/service/FollowMockingServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/follow/service/FollowMockingServiceTest.java @@ -22,6 +22,7 @@ import hanium.modic.backend.common.error.ErrorCode; import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.domain.follow.repository.FollowEntityRepository; +import hanium.modic.backend.domain.notification.service.NotificationService; import hanium.modic.backend.domain.user.entity.UserEntity; import hanium.modic.backend.domain.user.repository.UserEntityRepository; import hanium.modic.backend.domain.user.repository.UserImageEntityRepository; @@ -45,7 +46,8 @@ class FollowMockingServiceTest { private UserImageService userImageService; @Mock - private UserImageEntityRepository userImageEntityRepository; + private NotificationService notificationService; + @Test @DisplayName("TEST1: 존재하지 않는 유저의 팔로워 목록 조회 시 예외 발생") @@ -73,6 +75,7 @@ void followSuccess() { when(target.getId()).thenReturn(2L); when(userRepository.findById(2L)).thenReturn(Optional.of(target)); + doNothing().when(notificationService).createNotification(anyLong(), any(), any()); // when followService.followOrUnfollow(me, 2L, FOLLOW); diff --git a/src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java b/src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java index e0cad980..e7b104ac 100644 --- a/src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java @@ -16,7 +16,7 @@ import hanium.modic.backend.common.error.exception.LockException; import hanium.modic.backend.common.property.property.VoteProperties; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.user.entity.UserVoteStreak; import hanium.modic.backend.domain.user.repository.UserVoteStreakRepository; diff --git a/src/test/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestServiceTest.java b/src/test/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestServiceTest.java index 05bc89a3..d9690fd3 100644 --- a/src/test/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/vote/service/AiSimilarityRequestServiceTest.java @@ -1,6 +1,6 @@ package hanium.modic.backend.domain.vote.service; -import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*; +import static hanium.modic.backend.infra.amqp.config.RabbitMqConfig.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; diff --git a/src/test/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardServiceTest.java b/src/test/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardServiceTest.java index f75f7333..6efdbdf2 100644 --- a/src/test/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/vote/service/VoteCompletionRewardServiceTest.java @@ -16,7 +16,7 @@ import hanium.modic.backend.common.error.exception.AppException; import hanium.modic.backend.common.error.exception.LockException; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.ticket.service.TicketService; import hanium.modic.backend.domain.vote.entity.SimilarityVoteEntity; import hanium.modic.backend.domain.vote.entity.SimilarityVoteResultEntity; diff --git a/src/test/java/hanium/modic/backend/domain/vote/service/VotingServiceTest.java b/src/test/java/hanium/modic/backend/domain/vote/service/VotingServiceTest.java index 1de6c0de..4a71073a 100644 --- a/src/test/java/hanium/modic/backend/domain/vote/service/VotingServiceTest.java +++ b/src/test/java/hanium/modic/backend/domain/vote/service/VotingServiceTest.java @@ -11,7 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import hanium.modic.backend.common.property.property.VoteProperties; -import hanium.modic.backend.common.redis.distributedLock.LockManager; +import hanium.modic.backend.infra.redis.distributedLock.LockManager; import hanium.modic.backend.domain.post.repository.PostEntityRepository; import hanium.modic.backend.domain.user.service.UserVoteStreakService; import hanium.modic.backend.domain.vote.repository.SimilarityVoteRepository;