diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 3a090aba..89e38d65 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -25,6 +25,19 @@ jobs: with: redis-version: 6 + - name: create-json + id: create-json + uses: jsdaniell/create-json@1.1.2 + with: + name: "firebase-key.json" + json: ${{ secrets.FIREBASE_KEY }} + + - name: Move firebase-key.json to src/main/resources + run: | + mkdir -p ./src/main/resources + mv firebase-key.json ./src/main/resources/firebase-key.json + shell: bash + - name: Test with Gradle run: | ./gradlew test diff --git a/build.gradle b/build.gradle index 43b68c3b..16e07cab 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // firebase cloud messaging + implementation 'com.google.firebase:firebase-admin:9.2.0' } swaggerSources { diff --git a/src/main/java/com/projectlyrics/server/domain/auth/dto/request/AuthSignUpRequest.java b/src/main/java/com/projectlyrics/server/domain/auth/dto/request/AuthSignUpRequest.java index e2303218..a02c77b8 100644 --- a/src/main/java/com/projectlyrics/server/domain/auth/dto/request/AuthSignUpRequest.java +++ b/src/main/java/com/projectlyrics/server/domain/auth/dto/request/AuthSignUpRequest.java @@ -33,6 +33,8 @@ public record AuthSignUpRequest( @Valid List terms + + // TODO: fcmToken 추가 ) { public record TermsInput( diff --git a/src/main/java/com/projectlyrics/server/domain/comment/service/CommentCommandService.java b/src/main/java/com/projectlyrics/server/domain/comment/service/CommentCommandService.java index e127827a..a79df3f7 100644 --- a/src/main/java/com/projectlyrics/server/domain/comment/service/CommentCommandService.java +++ b/src/main/java/com/projectlyrics/server/domain/comment/service/CommentCommandService.java @@ -12,10 +12,12 @@ import com.projectlyrics.server.domain.note.entity.Note; import com.projectlyrics.server.domain.note.exception.NoteNotFoundException; import com.projectlyrics.server.domain.note.repository.NoteQueryRepository; +import com.projectlyrics.server.domain.notification.domain.event.CommentEvent; import com.projectlyrics.server.domain.user.entity.User; import com.projectlyrics.server.domain.user.exception.UserNotFoundException; import com.projectlyrics.server.domain.user.repository.UserQueryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,7 @@ public class CommentCommandService { private final CommentQueryRepository commentQueryRepository; private final UserQueryRepository userQueryRepository; private final NoteQueryRepository noteQueryRepository; + private final ApplicationEventPublisher eventPublisher; public Comment create(CommentCreateRequest request, Long writerId) { User writer = userQueryRepository.findById(writerId) @@ -37,7 +40,9 @@ public Comment create(CommentCreateRequest request, Long writerId) { Note note = noteQueryRepository.findById(request.noteId()) .orElseThrow(NoteNotFoundException::new); - return commentCommandRepository.save(Comment.create(CommentCreate.of(request.content(), writer, note))); + Comment comment = Comment.create(CommentCreate.of(request.content(), writer, note)); + eventPublisher.publishEvent(CommentEvent.from(comment)); + return commentCommandRepository.save(comment); } public Comment update(CommentUpdateRequest request, Long commentId, Long writerId) { diff --git a/src/main/java/com/projectlyrics/server/domain/common/message/ErrorCode.java b/src/main/java/com/projectlyrics/server/domain/common/message/ErrorCode.java index 2f53163e..1c2342de 100644 --- a/src/main/java/com/projectlyrics/server/domain/common/message/ErrorCode.java +++ b/src/main/java/com/projectlyrics/server/domain/common/message/ErrorCode.java @@ -71,6 +71,10 @@ public enum ErrorCode { // Bookmark BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "10000", "해당 북마크를 조회할 수 없습니다."), BOOKMARK_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "10001", "이미 북마크를 추가한 상태입니다."), + + // Notification + UNKNOWN_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST, "11000", "알 수 없는 알림 타입입니다."), + NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "11001", "알림 전송에 실패했습니다."), ; private final HttpStatus responseStatus; diff --git a/src/main/java/com/projectlyrics/server/domain/notification/api/NotificationController.java b/src/main/java/com/projectlyrics/server/domain/notification/api/NotificationController.java new file mode 100644 index 00000000..b83e9245 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/api/NotificationController.java @@ -0,0 +1,32 @@ +package com.projectlyrics.server.domain.notification.api; + +import com.projectlyrics.server.domain.auth.authentication.AuthContext; +import com.projectlyrics.server.domain.auth.authentication.Authenticated; +import com.projectlyrics.server.domain.notification.api.dto.request.PublicNotificationCreateRequest; +import com.projectlyrics.server.domain.notification.api.dto.response.PublicNotificationCreateResponse; +import com.projectlyrics.server.domain.notification.service.NotificationCommandService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + public final NotificationCommandService notificationCommandService; + + @PostMapping("/public") + public ResponseEntity createPublicNotification( + @Authenticated AuthContext authContext, + @RequestBody @Valid PublicNotificationCreateRequest request + ) { + notificationCommandService.createPublicNotification(authContext.getId(), request.content()); + + return ResponseEntity.ok(new PublicNotificationCreateResponse(true)); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/api/dto/request/PublicNotificationCreateRequest.java b/src/main/java/com/projectlyrics/server/domain/notification/api/dto/request/PublicNotificationCreateRequest.java new file mode 100644 index 00000000..8acb94ed --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/api/dto/request/PublicNotificationCreateRequest.java @@ -0,0 +1,9 @@ +package com.projectlyrics.server.domain.notification.api.dto.request; + +import jakarta.validation.constraints.NotEmpty; + +public record PublicNotificationCreateRequest( + @NotEmpty(message = "알림 내용이 비어 있습니다.") + String content +) { +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/api/dto/response/PublicNotificationCreateResponse.java b/src/main/java/com/projectlyrics/server/domain/notification/api/dto/response/PublicNotificationCreateResponse.java new file mode 100644 index 00000000..20775854 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/api/dto/response/PublicNotificationCreateResponse.java @@ -0,0 +1,6 @@ +package com.projectlyrics.server.domain.notification.api.dto.response; + +public record PublicNotificationCreateResponse( + boolean status +) { +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/domain/Notification.java b/src/main/java/com/projectlyrics/server/domain/notification/domain/Notification.java new file mode 100644 index 00000000..be5a14b1 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/domain/Notification.java @@ -0,0 +1,99 @@ +package com.projectlyrics.server.domain.notification.domain; + +import com.google.firebase.messaging.Message; +import com.projectlyrics.server.domain.comment.domain.Comment; +import com.projectlyrics.server.domain.common.entity.BaseEntity; +import com.projectlyrics.server.domain.note.entity.Note; +import com.projectlyrics.server.domain.notification.domain.event.CommentEvent; +import com.projectlyrics.server.domain.notification.domain.event.PublicEvent; +import com.projectlyrics.server.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notifications") +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private NotificationType type; + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + private User sender; + @ManyToOne(fetch = FetchType.LAZY) + private User receiver; + + @ManyToOne(fetch = FetchType.LAZY) + private Note note; + @ManyToOne(fetch = FetchType.LAZY) + private Comment comment; + + private Notification( + Long id, + NotificationType type, + String content, + User sender, + User receiver, + Note note, + Comment comment + ) { + this.id = id; + this.type = type; + this.sender = sender; + this.content = content; + this.receiver = receiver; + this.note = note; + this.comment = comment; + } + + public static Notification create(CommentEvent event) { + return new Notification( + null, + NotificationType.COMMENT_ON_NOTE, + null, + event.sender(), + event.receiver(), + event.note(), + event.comment() + ); + } + + public static Notification create(PublicEvent event) { + return new Notification( + null, + NotificationType.PUBLIC, + event.content(), + event.sender(), + event.receiver(), + null, + null + ); + } + + public Message getMessage() { + Message.Builder builder = Message.builder() + .setToken(receiver.getFcmToken()); + + switch (type) { + case COMMENT_ON_NOTE: + return builder + .putData("type", type.name()) + .putData("senderId", sender.getId().toString()) + .putData("senderNickname", sender.getNickname().getValue()) + .putData("noteId", note.getId().toString()) + .putData("noteTitle", note.getContent()) + .build(); + default: + throw new IllegalArgumentException("Invalid notification type"); + } + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/domain/NotificationType.java b/src/main/java/com/projectlyrics/server/domain/notification/domain/NotificationType.java new file mode 100644 index 00000000..5ca819eb --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/domain/NotificationType.java @@ -0,0 +1,8 @@ +package com.projectlyrics.server.domain.notification.domain; + +public enum NotificationType { + COMMENT_ON_NOTE, + REPORT, + PUBLIC, + ; +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/domain/event/CommentEvent.java b/src/main/java/com/projectlyrics/server/domain/notification/domain/event/CommentEvent.java new file mode 100644 index 00000000..497f516f --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/domain/event/CommentEvent.java @@ -0,0 +1,22 @@ +package com.projectlyrics.server.domain.notification.domain.event; + +import com.projectlyrics.server.domain.comment.domain.Comment; +import com.projectlyrics.server.domain.note.entity.Note; +import com.projectlyrics.server.domain.user.entity.User; + +public record CommentEvent( + User sender, + User receiver, + Note note, + Comment comment +) { + + public static CommentEvent from(Comment comment) { + return new CommentEvent( + comment.getWriter(), + comment.getNote().getPublisher(), + comment.getNote(), + comment + ); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/domain/event/PublicEvent.java b/src/main/java/com/projectlyrics/server/domain/notification/domain/event/PublicEvent.java new file mode 100644 index 00000000..cff3f667 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/domain/event/PublicEvent.java @@ -0,0 +1,14 @@ +package com.projectlyrics.server.domain.notification.domain.event; + +import com.projectlyrics.server.domain.user.entity.User; + +public record PublicEvent( + User sender, + User receiver, + String content +) { + + public static PublicEvent of(String content, User sender, User receiver) { + return new PublicEvent(sender, receiver, content); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/exception/FailedToSendNotificationException.java b/src/main/java/com/projectlyrics/server/domain/notification/exception/FailedToSendNotificationException.java new file mode 100644 index 00000000..7f9f6cf6 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/exception/FailedToSendNotificationException.java @@ -0,0 +1,11 @@ +package com.projectlyrics.server.domain.notification.exception; + +import com.projectlyrics.server.domain.common.message.ErrorCode; +import com.projectlyrics.server.global.exception.FeelinException; + +public class FailedToSendNotificationException extends FeelinException { + + public FailedToSendNotificationException() { + super(ErrorCode.NOTIFICATION_SEND_FAILED); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/exception/UnknownNotificationException.java b/src/main/java/com/projectlyrics/server/domain/notification/exception/UnknownNotificationException.java new file mode 100644 index 00000000..5c640372 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/exception/UnknownNotificationException.java @@ -0,0 +1,11 @@ +package com.projectlyrics.server.domain.notification.exception; + +import com.projectlyrics.server.domain.common.message.ErrorCode; +import com.projectlyrics.server.global.exception.FeelinException; + +public class UnknownNotificationException extends FeelinException { + + public UnknownNotificationException() { + super(ErrorCode.UNKNOWN_NOTIFICATION_TYPE); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationCommandRepository.java b/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationCommandRepository.java new file mode 100644 index 00000000..19cfe5bf --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationCommandRepository.java @@ -0,0 +1,9 @@ +package com.projectlyrics.server.domain.notification.repository; + +import com.projectlyrics.server.domain.notification.domain.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationCommandRepository extends JpaRepository { +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationQueryRepository.java b/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationQueryRepository.java new file mode 100644 index 00000000..10a2e298 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/repository/NotificationQueryRepository.java @@ -0,0 +1,11 @@ +package com.projectlyrics.server.domain.notification.repository; + +import com.projectlyrics.server.domain.notification.domain.Notification; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface NotificationQueryRepository { + + Slice findAllByReceiverId(Long receiverId, Long cursorId, Pageable pageable); + Slice findAllBySenderId(Long senderId, Long cursorId, Pageable pageable); +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/repository/impl/QueryDslNotificationQueryRepository.java b/src/main/java/com/projectlyrics/server/domain/notification/repository/impl/QueryDslNotificationQueryRepository.java new file mode 100644 index 00000000..cb72cd20 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/repository/impl/QueryDslNotificationQueryRepository.java @@ -0,0 +1,62 @@ +package com.projectlyrics.server.domain.notification.repository.impl; + +import com.projectlyrics.server.domain.common.util.QueryDslUtils; +import com.projectlyrics.server.domain.notification.domain.Notification; +import com.projectlyrics.server.domain.notification.repository.NotificationQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.projectlyrics.server.domain.notification.domain.QNotification.notification; + +@Repository +@RequiredArgsConstructor +public class QueryDslNotificationQueryRepository implements NotificationQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findAllByReceiverId(Long receiverId, Long cursorId, Pageable pageable) { + List content = jpaQueryFactory + .selectFrom(notification) + .leftJoin(notification.sender).fetchJoin() + .leftJoin(notification.receiver).fetchJoin() + .leftJoin(notification.note).fetchJoin() + .leftJoin(notification.comment).fetchJoin() + .where( + notification.receiver.id.eq(receiverId), + QueryDslUtils.gtCursorId(cursorId, notification.id), + notification.deletedAt.isNull() + ) + .orderBy(notification.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + return new SliceImpl<>(content, pageable, QueryDslUtils.checkIfHasNext(pageable, content)); + } + + @Override + public Slice findAllBySenderId(Long senderId, Long cursorId, Pageable pageable) { + List content = jpaQueryFactory + .selectFrom(notification) + .leftJoin(notification.sender).fetchJoin() + .leftJoin(notification.receiver).fetchJoin() + .leftJoin(notification.note).fetchJoin() + .leftJoin(notification.comment).fetchJoin() + .where( + notification.sender.id.eq(senderId), + QueryDslUtils.gtCursorId(cursorId, notification.id), + notification.deletedAt.isNull() + ) + .orderBy(notification.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + return new SliceImpl<>(content, pageable, QueryDslUtils.checkIfHasNext(pageable, content)); + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/notification/service/NotificationCommandService.java b/src/main/java/com/projectlyrics/server/domain/notification/service/NotificationCommandService.java new file mode 100644 index 00000000..ed13c6bd --- /dev/null +++ b/src/main/java/com/projectlyrics/server/domain/notification/service/NotificationCommandService.java @@ -0,0 +1,86 @@ +package com.projectlyrics.server.domain.notification.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MulticastMessage; +import com.projectlyrics.server.domain.notification.domain.Notification; +import com.projectlyrics.server.domain.notification.domain.event.CommentEvent; +import com.projectlyrics.server.domain.notification.domain.event.PublicEvent; +import com.projectlyrics.server.domain.notification.exception.FailedToSendNotificationException; +import com.projectlyrics.server.domain.notification.repository.NotificationCommandRepository; +import com.projectlyrics.server.domain.user.entity.User; +import com.projectlyrics.server.domain.user.exception.UserNotFoundException; +import com.projectlyrics.server.domain.user.repository.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class NotificationCommandService { + + private final NotificationCommandRepository notificationCommandRepository; + private final UserQueryRepository userQueryRepository; + private final FirebaseMessaging firebaseMessaging; + + @Async + @EventListener + public void createCommentNotification(CommentEvent event) { + Notification notification = notificationCommandRepository.save(Notification.create(event)); + send(notification); + } + + private void send(Notification notification) { + try { + firebaseMessaging.send(notification.getMessage()); + } catch (FirebaseMessagingException e) { + log.info(e.getMessage()); + throw new FailedToSendNotificationException(); + } + } + + public void createPublicNotification(Long adminId, String content) { + // TODO: adminId 확인하는 로직 추가 + User admin = userQueryRepository.findById(adminId) + .orElseThrow(UserNotFoundException::new); + List receivers = userQueryRepository.findAll(); + + List notifications = receivers.stream() + .map(receiver -> Notification.create(PublicEvent.of(content, admin, receiver))) + .toList(); + notificationCommandRepository.saveAll(notifications); + + sendAll(notifications, content); + } + + private void sendAll(List notifications, String content) { + int batchSize = notifications.size() / 500 + 1; + + for (int i = 0; i < batchSize; i++) { + List fcmTokens = notifications.subList(i * 500, Math.min((i + 1) * 500, notifications.size())) + .stream() + .map(Notification::getReceiver) + .map(User::getFcmToken) + .toList(); + + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(fcmTokens) + .putData("content", content) + .build(); + + try { + firebaseMessaging.sendEachForMulticast(message); + } catch (FirebaseMessagingException e) { + log.info(e.getMessage()); + throw new FailedToSendNotificationException(); + } + } + } +} diff --git a/src/main/java/com/projectlyrics/server/domain/user/entity/User.java b/src/main/java/com/projectlyrics/server/domain/user/entity/User.java index 6fa9a5d1..b565336b 100644 --- a/src/main/java/com/projectlyrics/server/domain/user/entity/User.java +++ b/src/main/java/com/projectlyrics/server/domain/user/entity/User.java @@ -50,6 +50,8 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List termsAgreements; + private String fcmToken; + private User( Long id, SocialInfo socialInfo, @@ -58,7 +60,8 @@ private User( Role role, Gender gender, Integer birthYear, - List termsAgreements + List termsAgreements, + String fcmToken ) { checkNull(socialInfo); checkNull(termsAgreements); @@ -71,6 +74,7 @@ private User( this.info = new UserMetaInfo(gender, birthYear); this.termsAgreements = termsAgreements; termsAgreements.forEach(terms -> terms.setUser(this)); + this.fcmToken = fcmToken; } private User( @@ -80,9 +84,10 @@ private User( Role role, Gender gender, Integer birthYear, - List termsAgreements + List termsAgreements, + String fcmToken ) { - this(null, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements); + this(null, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements, fcmToken); } public static User withId( @@ -93,9 +98,10 @@ public static User withId( Role role, Gender gender, Integer birthYear, - List termsAgreements + List termsAgreements, + String fcmToken ) { - return new User(id, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements); + return new User(id, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements, fcmToken); } public static User create(UserCreate userCreate) { @@ -106,19 +112,8 @@ public static User create(UserCreate userCreate) { Role.USER, userCreate.gender(), userCreate.birthYear(), - userCreate.termsAgreements() + userCreate.termsAgreements(), + userCreate.fcmToken() ); } - - public static User of( - SocialInfo socialInfo, - String nickname, - ProfileCharacter profileCharacter, - Role role, - Gender gender, - Integer birthYear, - List termsAgreements - ) { - return new User(socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements); - } } diff --git a/src/main/java/com/projectlyrics/server/domain/user/entity/UserCreate.java b/src/main/java/com/projectlyrics/server/domain/user/entity/UserCreate.java index dba5120c..04fdab1b 100644 --- a/src/main/java/com/projectlyrics/server/domain/user/entity/UserCreate.java +++ b/src/main/java/com/projectlyrics/server/domain/user/entity/UserCreate.java @@ -11,7 +11,8 @@ public record UserCreate( ProfileCharacter profileCharacter, Gender gender, Integer birthYear, - List termsAgreements + List termsAgreements, + String fcmToken ) { public static UserCreate of(SocialInfo socialInfo, AuthSignUpRequest request) { @@ -25,7 +26,8 @@ public static UserCreate of(SocialInfo socialInfo, AuthSignUpRequest request) { request.profileCharacter(), request.gender(), Objects.nonNull(request.birthYear()) ? request.birthYear().getValue() : null, - termsList + termsList, + null ); } } diff --git a/src/main/java/com/projectlyrics/server/domain/user/repository/UserQueryRepository.java b/src/main/java/com/projectlyrics/server/domain/user/repository/UserQueryRepository.java index b5494b24..29ac7c07 100644 --- a/src/main/java/com/projectlyrics/server/domain/user/repository/UserQueryRepository.java +++ b/src/main/java/com/projectlyrics/server/domain/user/repository/UserQueryRepository.java @@ -4,6 +4,7 @@ import com.projectlyrics.server.domain.user.entity.SocialInfo; import com.projectlyrics.server.domain.user.entity.User; +import java.util.List; import java.util.Optional; public interface UserQueryRepository { @@ -12,5 +13,7 @@ public interface UserQueryRepository { Optional findById(Long id); + List findAll(); + boolean existsBySocialInfo(SocialInfo socialInfo); } diff --git a/src/main/java/com/projectlyrics/server/domain/user/repository/impl/QueryDslUserQueryRepository.java b/src/main/java/com/projectlyrics/server/domain/user/repository/impl/QueryDslUserQueryRepository.java index 9292460b..156d4c02 100644 --- a/src/main/java/com/projectlyrics/server/domain/user/repository/impl/QueryDslUserQueryRepository.java +++ b/src/main/java/com/projectlyrics/server/domain/user/repository/impl/QueryDslUserQueryRepository.java @@ -7,11 +7,14 @@ import com.projectlyrics.server.domain.user.repository.UserQueryRepository; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static com.projectlyrics.server.domain.user.entity.QUser.user; + @Repository @RequiredArgsConstructor public class QueryDslUserQueryRepository implements UserQueryRepository { @@ -22,11 +25,11 @@ public class QueryDslUserQueryRepository implements UserQueryRepository { public Optional findBySocialIdAndAuthProvider(String socialId, AuthProvider authProvider) { return Optional.ofNullable( jpaQueryFactory - .selectFrom(QUser.user) + .selectFrom(user) .where( - QUser.user.socialInfo.socialId.eq(socialId), - QUser.user.socialInfo.authProvider.eq(authProvider), - QUser.user.deletedAt.isNull() + user.socialInfo.socialId.eq(socialId), + user.socialInfo.authProvider.eq(authProvider), + user.deletedAt.isNull() ) .fetchOne() ); @@ -36,21 +39,31 @@ public Optional findBySocialIdAndAuthProvider(String socialId, AuthProvide public Optional findById(Long id) { return Optional.ofNullable( jpaQueryFactory - .selectFrom(QUser.user) + .selectFrom(user) .where( - QUser.user.id.eq(id), - QUser.user.deletedAt.isNull() + user.id.eq(id), + user.deletedAt.isNull() ) .fetchOne() ); } + @Override + public List findAll() { + return jpaQueryFactory + .selectFrom(user) + .where( + user.deletedAt.isNull() + ) + .fetch(); + } + @Override public boolean existsBySocialInfo(SocialInfo socialInfo) { return Optional.ofNullable( jpaQueryFactory - .selectFrom(QUser.user) - .where(QUser.user.socialInfo.eq(socialInfo)) + .selectFrom(user) + .where(user.socialInfo.eq(socialInfo)) .fetchOne() ).isPresent(); } diff --git a/src/main/java/com/projectlyrics/server/global/configuration/AsyncConfig.java b/src/main/java/com/projectlyrics/server/global/configuration/AsyncConfig.java new file mode 100644 index 00000000..2d1e44fd --- /dev/null +++ b/src/main/java/com/projectlyrics/server/global/configuration/AsyncConfig.java @@ -0,0 +1,26 @@ +package com.projectlyrics.server.global.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean + public Executor threadPoolTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(3); + executor.setMaxPoolSize(30); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-executor-"); + + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/projectlyrics/server/global/configuration/FirebaseConfig.java b/src/main/java/com/projectlyrics/server/global/configuration/FirebaseConfig.java new file mode 100644 index 00000000..30cb5932 --- /dev/null +++ b/src/main/java/com/projectlyrics/server/global/configuration/FirebaseConfig.java @@ -0,0 +1,41 @@ +package com.projectlyrics.server.global.configuration; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.FileInputStream; +import java.io.IOException; + +@Slf4j +@Configuration +public class FirebaseConfig { + + @PostConstruct + public void init() { + try { + if (FirebaseApp.getApps().isEmpty()) { + FileInputStream key = new FileInputStream("src/main/resources/firebase-key.json"); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(key)) + .build(); + + FirebaseApp.initializeApp(options); + } + } catch (IOException e) { + log.error("failed to initialize firebase", e); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + log.info("firebase messaging initialized"); + return FirebaseMessaging.getInstance(); + } +} diff --git a/src/test/java/com/projectlyrics/server/domain/comment/service/CommentCommandServiceMockTest.java b/src/test/java/com/projectlyrics/server/domain/comment/service/CommentCommandServiceMockTest.java new file mode 100644 index 00000000..bba049d6 --- /dev/null +++ b/src/test/java/com/projectlyrics/server/domain/comment/service/CommentCommandServiceMockTest.java @@ -0,0 +1,60 @@ +package com.projectlyrics.server.domain.comment.service; + +import com.projectlyrics.server.domain.comment.domain.Comment; +import com.projectlyrics.server.domain.comment.dto.request.CommentCreateRequest; +import com.projectlyrics.server.domain.comment.repository.CommentCommandRepository; +import com.projectlyrics.server.domain.comment.repository.CommentQueryRepository; +import com.projectlyrics.server.domain.note.entity.Note; +import com.projectlyrics.server.domain.note.repository.NoteQueryRepository; +import com.projectlyrics.server.domain.notification.domain.event.CommentEvent; +import com.projectlyrics.server.domain.user.entity.User; +import com.projectlyrics.server.domain.user.repository.UserQueryRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CommentCommandServiceMockTest { + + @Mock + private CommentCommandRepository commentCommandRepository; + + @Mock + private CommentQueryRepository commentQueryRepository; + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private NoteQueryRepository noteQueryRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private CommentCommandService commentCommandService; + + @Test + void 댓글을_저장할_때_이벤트가_발행된다() { + // given + CommentCreateRequest request = new CommentCreateRequest("댓글 내용", 1L); + when(userQueryRepository.findById(anyLong())).thenReturn(Optional.of(mock(User.class))); + when(noteQueryRepository.findById(anyLong())).thenReturn(Optional.of(mock(Note.class))); + when(commentCommandRepository.save(any(Comment.class))).thenReturn(mock(Comment.class)); + + // when + commentCommandService.create(request, 1L); + + // then + verify(eventPublisher).publishEvent(any(CommentEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/projectlyrics/server/domain/notification/api/NotificationControllerTest.java b/src/test/java/com/projectlyrics/server/domain/notification/api/NotificationControllerTest.java new file mode 100644 index 00000000..81af4510 --- /dev/null +++ b/src/test/java/com/projectlyrics/server/domain/notification/api/NotificationControllerTest.java @@ -0,0 +1,55 @@ +package com.projectlyrics.server.domain.notification.api; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; +import com.projectlyrics.server.domain.notification.api.dto.request.PublicNotificationCreateRequest; +import com.projectlyrics.server.support.RestDocsTest; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.JsonFieldType; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class NotificationControllerTest extends RestDocsTest { + + @Test + void 전체_알림을_등록하면_결과_상태값과_200응답을_해야_한다() throws Exception { + // given + PublicNotificationCreateRequest request = new PublicNotificationCreateRequest("content"); + + // when, then + mockMvc.perform(post("/api/v1/notifications/public") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()) + .andDo(getPublicNotificationCreateDocument()); + } + + private RestDocumentationResultHandler getPublicNotificationCreateDocument() { + return restDocs.document( + resource(ResourceSnippetParameters.builder() + .tag("Notification API") + .summary("전체 알림 등록 API") + .requestHeaders(getAuthorizationHeader()) + .requestFields( + fieldWithPath("content").type(JsonFieldType.STRING) + .description("알림 내용") + ) + .requestSchema(Schema.schema("Create Public Notification Request")) + .responseFields( + fieldWithPath("status").type(JsonFieldType.BOOLEAN) + .description("요청 처리 상태") + ) + .responseSchema(Schema.schema("Create Public Notification Response")) + .build()) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/projectlyrics/server/domain/notification/service/NotificationCommandServiceTest.java b/src/test/java/com/projectlyrics/server/domain/notification/service/NotificationCommandServiceTest.java new file mode 100644 index 00000000..b681512a --- /dev/null +++ b/src/test/java/com/projectlyrics/server/domain/notification/service/NotificationCommandServiceTest.java @@ -0,0 +1,137 @@ +package com.projectlyrics.server.domain.notification.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.projectlyrics.server.domain.artist.entity.Artist; +import com.projectlyrics.server.domain.artist.repository.ArtistCommandRepository; +import com.projectlyrics.server.domain.comment.domain.Comment; +import com.projectlyrics.server.domain.comment.domain.CommentCreate; +import com.projectlyrics.server.domain.comment.repository.CommentCommandRepository; +import com.projectlyrics.server.domain.note.dto.request.NoteCreateRequest; +import com.projectlyrics.server.domain.note.entity.Note; +import com.projectlyrics.server.domain.note.entity.NoteBackground; +import com.projectlyrics.server.domain.note.entity.NoteCreate; +import com.projectlyrics.server.domain.note.entity.NoteStatus; +import com.projectlyrics.server.domain.note.repository.NoteCommandRepository; +import com.projectlyrics.server.domain.notification.domain.Notification; +import com.projectlyrics.server.domain.notification.domain.NotificationType; +import com.projectlyrics.server.domain.notification.domain.event.CommentEvent; +import com.projectlyrics.server.domain.notification.repository.NotificationCommandRepository; +import com.projectlyrics.server.domain.notification.repository.NotificationQueryRepository; +import com.projectlyrics.server.domain.song.entity.Song; +import com.projectlyrics.server.domain.song.repository.SongCommandRepository; +import com.projectlyrics.server.domain.user.entity.User; +import com.projectlyrics.server.domain.user.repository.UserCommandRepository; +import com.projectlyrics.server.support.IntegrationTest; +import com.projectlyrics.server.support.fixture.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.*; + +class NotificationCommandServiceTest extends IntegrationTest { + + @Autowired + UserCommandRepository userCommandRepository; + + @Autowired + ArtistCommandRepository artistCommandRepository; + + @Autowired + SongCommandRepository songCommandRepository; + + @Autowired + NoteCommandRepository noteCommandRepository; + + @Autowired + CommentCommandRepository commentCommandRepository; + + @Autowired + NotificationCommandRepository notificationCommandRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @MockBean + FirebaseMessaging firebaseMessaging; + + @Autowired + NotificationCommandService sut; + + private Comment comment; + private User user; + private List users = new ArrayList<>(); + + @BeforeEach + void setUp() { + for (int i = 0; i < 100; i++) { + users.add(userCommandRepository.save(UserFixture.create())); + } + user = users.getFirst(); + + Artist artist = artistCommandRepository.save(ArtistFixture.create()); + Song song = songCommandRepository.save(SongFixture.create(artist)); + + NoteCreateRequest noteCreateRequest = new NoteCreateRequest( + "content", + "lyrics", + NoteBackground.DEFAULT, + NoteStatus.PUBLISHED, + song.getId() + ); + Note note = noteCommandRepository.save(Note.create(NoteCreate.from(noteCreateRequest, user, song))); + + comment = commentCommandRepository.save(Comment.create(CommentCreate.of("content", user, note))); + } + + @Test + void 댓글에_대한_알림을_저장한다() throws Exception { + // given + CommentEvent commentEvent = CommentEvent.from(comment); + when(firebaseMessaging.send(any())).thenReturn(null); + + // when + sut.createCommentNotification(commentEvent); + + // then + List result = notificationQueryRepository.findAllByReceiverId(user.getId(), null, PageRequest.ofSize(10)) + .getContent(); + + assertAll( + () -> assertThat(result.size()).isEqualTo(1), + () -> assertThat(result.get(0).getType()).isEqualTo(NotificationType.COMMENT_ON_NOTE), + () -> assertThat(result.get(0).getSender()).isEqualTo(comment.getWriter()), + () -> assertThat(result.get(0).getReceiver()).isEqualTo(comment.getNote().getPublisher()), + () -> assertThat(result.get(0).getNote()).isEqualTo(comment.getNote()), + () -> assertThat(result.get(0).getComment()).isEqualTo(comment) + ); + } + + @Test + void 공개_알림을_저장한다() throws Exception { + // given + when(firebaseMessaging.sendEachForMulticast(any())).thenReturn(null); + String content = "content"; + + // when + sut.createPublicNotification(user.getId(), content); + + // then + List result = notificationQueryRepository.findAllBySenderId(user.getId(), null, PageRequest.ofSize(10)) + .getContent(); + + assertAll( + () -> assertThat(result.size()).isEqualTo(10), + () -> assertThat(result.stream().allMatch(notification -> notification.getType().equals(NotificationType.PUBLIC))).isTrue(), + () -> assertThat(result.stream().allMatch(notification -> notification.getSender().getId().equals(user.getId()))).isTrue(), + () -> assertThat(result.stream().allMatch(notification -> notification.getContent().equalsIgnoreCase(content))).isTrue() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/projectlyrics/server/support/ControllerTest.java b/src/test/java/com/projectlyrics/server/support/ControllerTest.java index e1325d94..c7bec69b 100644 --- a/src/test/java/com/projectlyrics/server/support/ControllerTest.java +++ b/src/test/java/com/projectlyrics/server/support/ControllerTest.java @@ -18,6 +18,7 @@ import com.projectlyrics.server.domain.like.service.LikeQueryService; import com.projectlyrics.server.domain.note.service.NoteCommandService; import com.projectlyrics.server.domain.note.service.NoteQueryService; +import com.projectlyrics.server.domain.notification.service.NotificationCommandService; import com.projectlyrics.server.domain.song.service.SongQueryService; import com.projectlyrics.server.global.configuration.ClockConfig; import org.junit.jupiter.api.BeforeEach; @@ -85,6 +86,9 @@ public abstract class ControllerTest { @MockBean protected BookmarkCommandService bookmarkCommandService; + @MockBean + protected NotificationCommandService notificationCommandService; + public String accessToken; public String refreshToken; diff --git a/src/test/java/com/projectlyrics/server/support/fixture/UserFixture.java b/src/test/java/com/projectlyrics/server/support/fixture/UserFixture.java index 5b26c58d..f9ad3a3a 100644 --- a/src/test/java/com/projectlyrics/server/support/fixture/UserFixture.java +++ b/src/test/java/com/projectlyrics/server/support/fixture/UserFixture.java @@ -21,8 +21,10 @@ public class UserFixture extends BaseFixture { private int birthYear = 1999; private Role role = Role.USER; private List termsAgreements = List.of(new TermsAgreements(true, "title", "agreement")); + private String fcmToken = "fcmToken"; - private UserFixture() {} + private UserFixture() { + } public static User create() { return User.withId( @@ -33,8 +35,9 @@ public static User create() { Role.USER, Gender.MALE, 1999, - List.of(new TermsAgreements(true, "title", "agreement") - )); + List.of(new TermsAgreements(true, "title", "agreement")), + "fcmToken" + ); } public static UserFixture builder() { @@ -42,7 +45,7 @@ public static UserFixture builder() { } public User build() { - return User.withId(id, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements); + return User.withId(id, socialInfo, nickname, profileCharacter, role, gender, birthYear, termsAgreements, fcmToken); } public UserFixture role(Role role) {