Skip to content
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ dependencies {
implementation platform("software.amazon.awssdk:bom:2.25.62")
implementation "software.amazon.awssdk:s3"

// FCM
implementation 'com.google.firebase:firebase-admin:9.2.0'
}

tasks.named('test') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/Mua/Mua_backend/MuaBackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableJpaAuditing
@SpringBootApplication
@EnableScheduling
public class MuaBackendApplication {

public static void main(String[] args) {
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/Mua/Mua_backend/domain/feed/entity/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public class Feed extends BaseTimeEntity {
@JoinColumn(name = "member_id", nullable = false)
private Member writer;

// 중복 알림 방지
@Column(nullable = false)
private boolean gameStarted = false;

@Builder
private Feed(
String image,
Expand Down Expand Up @@ -184,4 +188,7 @@ private void validateLocation(Double latitude, Double longitude) {
}
}

public void markGameStarted() {
this.gameStarted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ List<Feed> findNearestFeeds(
Pageable pageable
);

@Query("""
select f from Feed f
where f.playDate <= :now
and f.gameStarted = false
""")
List<Feed> findFeedsToStart(LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package Mua.Mua_backend.domain.feed.scheduler;

import Mua.Mua_backend.domain.feed.entity.Feed;
import Mua.Mua_backend.domain.feed.repository.FeedRepository;
import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.domain.notification.entity.NotificationType;
import Mua.Mua_backend.domain.notification.service.NotificationService;
import Mua.Mua_backend.domain.participation.entity.ParticipationStatus;
import Mua.Mua_backend.domain.participation.repository.ParticipationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Component
@RequiredArgsConstructor
public class GameStartScheduler {

private final FeedRepository feedRepository;
private final ParticipationRepository participationRepository;
private final NotificationService notificationService;

// 게임 시작 알림 playDate <= now 승인된, 참가자(APPROVED)에게만 전송, 1분마다 실행
@Scheduled(fixedRate = 60000)
@Transactional
public void notifyGameStart() {
LocalDateTime now = LocalDateTime.now();

// 시작 시간이 된 Feed 조회
List<Feed> feeds = feedRepository.findFeedsToStart(now);

for (Feed feed : feeds) {

// 승인된 참가자 조회
List<Member> approvedMembers =
participationRepository.findMembersByFeedAndStatus(
feed.getId(),
ParticipationStatus.APPROVED
);

// 알림 전송
for (Member member : approvedMembers) {
notificationService.sendNotification(
member,
NotificationType.GAME_STARTED,
feed.getId(),
"경기가 시작되었습니다."
);
}

// 중복 알림 방지용 상태 변경
feed.markGameStarted();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package Mua.Mua_backend.domain.member.controller;

import Mua.Mua_backend.domain.member.dto.request.FcmTokenRequest;
import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.domain.member.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/members")
public class MemberController {

private final MemberService memberService;

// FCM 토큰 저장 / 갱신, 로그인 성공 직후 호출, 기존 토큰 있으면 덮어씀
@PostMapping("/fcm-token")
public ResponseEntity<Void> saveFcmToken(
@AuthenticationPrincipal Member member,
@RequestBody @Valid FcmTokenRequest request
) {
memberService.updateFcmToken(member, request.fcmToken());
return ResponseEntity.noContent().build();
}

// FCM 토큰 삭제 (로그아웃 시)
@DeleteMapping("/fcm-token")
public ResponseEntity<Void> deleteFcmToken(
@AuthenticationPrincipal Member member
) {
memberService.removeFcmToken(member);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package Mua.Mua_backend.domain.member.dto.request;

import jakarta.validation.constraints.NotBlank;

public record FcmTokenRequest(
@NotBlank String fcmToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public class Member {
@Column(nullable = false)
private String providerId;

@Column(name = "fcm_token")
private String fcmToken;

public static Member createOAuthUser(
String email,
String nickname,
Expand Down Expand Up @@ -67,4 +70,6 @@ public void clearRefreshToken() {
public boolean isAdmin() {
return this.role == Role.ADMIN;
}

public void updateFcmToken(String fcmToken) { this.fcmToken = fcmToken; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package Mua.Mua_backend.domain.member.service;

import Mua.Mua_backend.domain.member.entity.Member;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

public void updateFcmToken(Member member, String fcmToken) {
member.updateFcmToken(fcmToken);
}

public void removeFcmToken(Member member) {
member.updateFcmToken(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package Mua.Mua_backend.domain.notification.controller;

import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.domain.notification.dto.response.NotificationResponse;
import Mua.Mua_backend.domain.notification.repository.NotificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications")
public class NotificationController {

private final NotificationRepository notificationRepository;

@GetMapping
public List<NotificationResponse> getMyNotifications(
@AuthenticationPrincipal Member member
) {
return notificationRepository
.findByMemberOrderByCreatedAtDesc(member)
.stream()
.map(NotificationResponse::from)
.toList();
}

// 알림 전체 삭제
@DeleteMapping
public ResponseEntity<Void> deleteMyNotifications(
@AuthenticationPrincipal Member member
) {
notificationRepository.deleteByMember(member);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package Mua.Mua_backend.domain.notification.dto.response;

import Mua.Mua_backend.domain.notification.entity.Notification;
import Mua.Mua_backend.domain.notification.entity.NotificationType;

import java.time.LocalDateTime;

public record NotificationResponse (
Long notificationId,
String message,
boolean read,
LocalDateTime createdAt,
NotificationType targetType,
Long targetId
) {
public static NotificationResponse from(Notification notification) {
return new NotificationResponse(
notification.getNotificationId(),
notification.getMessage(),
notification.isRead(),
notification.getCreatedAt(),
notification.getTargetType(),
notification.getTargetId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package Mua.Mua_backend.domain.notification.entity;

import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.global.common.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long notificationId;

private String message;

private boolean read;

@Enumerated(EnumType.STRING)
private NotificationType targetType;

private Long targetId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@Builder
public Notification(Member member, String message,
NotificationType targetType, Long targetId) {
this.member = member;
this.message = message;
this.targetType = targetType;
this.targetId = targetId;
this.read = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package Mua.Mua_backend.domain.notification.entity;

public enum NotificationType {
PARTICIPATION_APPROVED,
PARTICIPATION_REJECTED,
GAME_STARTED,
GAME_FINISHED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package Mua.Mua_backend.domain.notification.repository;

import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.domain.notification.entity.Notification;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface NotificationRepository extends JpaRepository<Notification, Long> {

List<Notification> findByMemberOrderByCreatedAtDesc(Member member);

void deleteByMember(Member member);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package Mua.Mua_backend.domain.notification.service;

import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.domain.notification.entity.Notification;
import Mua.Mua_backend.domain.notification.entity.NotificationType;
import Mua.Mua_backend.domain.notification.repository.NotificationRepository;
import Mua.Mua_backend.global.fcm.FcmService;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Transactional
public class NotificationService {

private final NotificationRepository notificationRepository;
private final FcmService fcmService;

public void sendNotification(
Member targetMember,
NotificationType type,
Long targetId,
String message
) {
Notification notification = new Notification(
targetMember,
message,
type,
targetId
);

notificationRepository.save(notification);

fcmService.send(targetMember.getFcmToken(), message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,15 @@ List<Participation> findNextPageWithStatus(
@Param("cursorId") Long cursorId,
Pageable pageable
);

@Query("""
select p.applicant
from Participation p
where p.feed.id = :feedId
and p.status = :status
""")
List<Member> findMembersByFeedAndStatus(
Long feedId,
ParticipationStatus status
);
}
Loading