Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies {
implementation 'com.amazonaws:aws-java-sdk-core:1.12.681'
implementation 'org.apache.commons:commons-lang3:3.12.0'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'com.google.firebase:firebase-admin:9.2.0'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,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;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class BackendApplication {
public static void main(String[] args) {
// .env 파일 로딩 (없어도 실행되게 ignoreIfMissing 추가)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package org.example.backend.domain.lecture.repository;

import io.lettuce.core.dynamic.annotation.Param;
import org.example.backend.domain.classroom.entity.Classroom;
import org.example.backend.domain.lecture.entity.Lecture;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.UUID;

Expand All @@ -15,4 +18,12 @@ public interface LectureRepository extends JpaRepository<Lecture, UUID> {
List<Lecture> findByClassroomInAndLectureDate(List<Classroom> classrooms, LocalDate lectureDate);
List<Lecture> findByClassroom_IdOrderByLectureDateAscStartTimeAsc(UUID classId);

@Query("SELECT l FROM Lecture l " +
"WHERE l.lectureDate = :today " +
"AND l.startTime = :targetTime " +
"AND l.isLectureStart = false")
List<Lecture> findLecturesStartingAt(
@Param("today") LocalDate today,
@Param("targetTime") LocalTime targetTime
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.example.backend.domain.lecture.service;

import lombok.RequiredArgsConstructor;
import org.example.backend.domain.lecture.entity.Lecture;
import org.example.backend.domain.lecture.repository.LectureRepository;
import org.example.backend.domain.notification.entity.AlarmType;
import org.example.backend.domain.notification.service.NotificationService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;

@Component
@RequiredArgsConstructor
public class LectureScheduler {

private final LectureRepository lectureRepository;
private final NotificationService notificationService;

// 매 분마다 실행
@Scheduled(cron = "0 * * * * *")
public void notifyProfessorBeforeLecture() {
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now().withSecond(0).withNano(0);

// 🔥 "현재 시각 + 10분"이 lecture start_time 인 강의 찾기
LocalTime targetStartTime = now.plusMinutes(10);

List<Lecture> lectures = lectureRepository.findLecturesStartingAt(today, targetStartTime);

for (Lecture lecture : lectures) {
notificationService.sendAlarmToProfessor(
lecture.getId(),
AlarmType.startLecture,
"시스템",
lecture.getLectureName() + " 강의가 10분 후 시작됩니다."
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.example.backend.global.ApiResponse;
import org.example.backend.global.security.auth.CustomSecurityUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -28,4 +29,10 @@ public ApiResponse<List<NotificationResponseDTO>> getNotifications() {
List<NotificationResponseDTO> notifications = notificationService.getNotificationsByUserId(userId);
return ApiResponse.onSuccess(notifications);
}

@PatchMapping("/read-all")
public void markAllAsRead() {
UUID userId = customSecurityUtil.getUserId();
notificationService.markAllAsRead(userId);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package org.example.backend.domain.notification.repository;

import io.lettuce.core.dynamic.annotation.Param;
import org.example.backend.domain.classroom.entity.Classroom;
import org.example.backend.domain.notification.entity.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
Expand All @@ -11,4 +14,8 @@
@Repository
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
List<Notification> findByUserIdOrderByCreatedAtDesc(UUID userId);

@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.user.id = :userId")
void markAllAsReadByUserId(@Param("userId") UUID userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
import org.example.backend.domain.lecture.repository.LectureRepository;
import org.example.backend.domain.notification.converter.NotificationConverter;
import org.example.backend.domain.notification.dto.response.NotificationResponseDTO;
import org.example.backend.domain.notification.entity.AlarmType;
import org.example.backend.domain.notification.entity.Notification;
import org.example.backend.domain.notification.repository.NotificationRepository;
import org.example.backend.domain.notificationSetting.service.FcmService;
import org.example.backend.domain.notificationSetting.service.NotificationTemplateService;
import org.example.backend.global.userdeviceToken.repository.UserDeviceTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;
Expand All @@ -20,7 +25,10 @@ public class NotificationService implements NotificationServiceImpl{
private final NotificationRepository notificationRepository;
private final LectureRepository lectureRepository;
private final ClassroomRepository classroomRepository;
private final NotificationConverter notificationConverter;;
private final NotificationConverter notificationConverter;
private final NotificationTemplateService templateService;
private final UserDeviceTokenRepository tokenRepository;
private final FcmService fcmService;

public List<NotificationResponseDTO> getNotificationsByUserId(UUID userId) {
List<Notification> notificationList =
Expand All @@ -39,4 +47,32 @@ public List<NotificationResponseDTO> getNotificationsByUserId(UUID userId) {
})
.toList();
}

public void sendAlarmToProfessor(UUID lectureId, AlarmType type, String senderName, String extra) {
Lecture lecture = lectureRepository.findById(lectureId)
.orElseThrow(() -> new RuntimeException("Lecture not found"));

UUID professorId = lecture.getClassroom().getProfessor().getId();

String title = templateService.getTitle(type);
String body = templateService.getBody(type, senderName, extra);

var tokens = tokenRepository.findAllByUserIdAndIsActiveTrue(professorId);
tokens.forEach(token ->
fcmService.sendNotification(token.getFcmToken(), title, body)
);

Notification notification = Notification.builder()
.user(lecture.getClassroom().getProfessor())
.lecture(lecture)
.alarmType(type)
.isRead(false)
.build();
notificationRepository.save(notification);
}

@Transactional
public void markAllAsRead(UUID userId) {
notificationRepository.markAllAsReadByUserId(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public class NotificationSetting extends BaseEntity {
@Column(name = "user_id")
private String userId;

@Column(name = "token", nullable = false, unique = true, length = 512)
private String token;

@Column(name = "quiz_upload", nullable = false)
@Builder.Default
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.example.backend.domain.notificationSetting.service;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import org.springframework.stereotype.Service;

@Service
public class FcmService {

public void sendNotification(String fcmToken, String title, String body) {
try {
Message message = Message.builder()
.setToken(fcmToken)
.setNotification(Notification.builder()
.setTitle(title)
.setBody(body)
.build())
.build();

String response = FirebaseMessaging.getInstance().send(message);
System.out.println("✅ Sent message: " + response);

} catch (Exception e) {
System.err.println("❌ Failed to send FCM message: " + e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.example.backend.domain.notificationSetting.service;

import org.example.backend.domain.notification.entity.AlarmType;
import org.springframework.stereotype.Service;

@Service
public class NotificationTemplateService {

public String getTitle(AlarmType type) {
return switch (type) {
case quizUpload -> "📘 새 퀴즈 업로드";
case quizAnswerUpload -> "✍️ 퀴즈 답안 업로드";
case lectureNoteUpload -> "📄 강의 노트 업로드";
case startLecture -> "📢 강의 시작 알림";
case recordUpload -> "🎙️ 녹음 파일 업로드";
};
}

public String getBody(AlarmType type, String senderName, String extra) {
return switch (type) {
case quizUpload -> senderName + " 선생님이 퀴즈를 올리셨습니다: " + extra;
case quizAnswerUpload -> senderName + " 선생님이 퀴즈 답안을 업로드하셨습니다.";
case lectureNoteUpload -> senderName + " 선생님이 강의 노트를 공유하셨습니다.";
case startLecture -> senderName + " 선생님의 강의가 곧 시작됩니다. " + extra;
case recordUpload -> senderName + " 선생님이 강의 녹음을 업로드하셨습니다.";
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.example.backend.domain.lectureNote.repository.LectureNoteRepository;
import org.example.backend.domain.lectureNoteMapping.entity.LectureNoteMapping;
import org.example.backend.domain.lectureNoteMapping.repository.LectureNoteMappingRepository;
import org.example.backend.domain.notification.entity.AlarmType;
import org.example.backend.domain.notification.service.NotificationService;
import org.example.backend.domain.option.entity.Option;
import org.example.backend.domain.option.repository.OptionRepository;
import org.example.backend.domain.quiz.converter.QuizConverter;
Expand All @@ -26,9 +28,14 @@
import org.example.backend.global.security.auth.CustomSecurityUtil;
import org.example.backend.infra.langchain.LangChainClient;
import org.example.backend.global.S3.service.S3Service;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
Expand All @@ -50,6 +57,8 @@ public class QuizServiceImpl implements QuizService {
private final CustomSecurityUtil customSecurityUtil;
private final QuizConverter quizConverter;

private final TaskScheduler taskScheduler;
private final NotificationService notificationService;

// 퀴즈 생성 및 재생성
@Override
Expand Down Expand Up @@ -172,13 +181,34 @@ public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request)
}
}
}
scheduleQuizAnswerUploadNotification(lecture);


return QuizSaveResponseDTO.builder()
.lectureId(lectureId)
.savedCount(savedQuizIds.size())
.quizIds(savedQuizIds)
.build();
}
private void scheduleQuizAnswerUploadNotification(Lecture lecture) {
// 현재 시간 기준으로 "오늘 밤 12시(자정)" 계산
LocalDateTime midnight = LocalDate.now()
.plusDays(1) // 내일 0시 (오늘 밤 12시)
.atStartOfDay();

ZoneId zone = ZoneId.systemDefault();
Instant triggerTime = midnight.atZone(zone).toInstant();

taskScheduler.schedule(() -> {
notificationService.sendAlarmToProfessor(
lecture.getId(),
AlarmType.quizAnswerUpload,
"시스템",
lecture.getLectureName() + " 퀴즈 대시보드가 업로드 되었습니다."
);
}, triggerTime);
}


// 퀴즈 문제 조회
@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.example.backend.global.userdeviceToken;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.FileInputStream;
import java.io.IOException;


@Configuration
public class FCMConfig {
@Bean
public FirebaseApp firebaseApp() throws IOException {
if (FirebaseApp.getApps().isEmpty()) {
// 서비스 계정 키 JSON 파일 경로
FileInputStream serviceAccount =
new FileInputStream("src/main/resources/claog-1e23b-firebase-adminsdk-fbsvc-25a8b72901.json");

FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

return FirebaseApp.initializeApp(options);
}
return FirebaseApp.getInstance();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.example.backend.global.userdeviceToken.controller;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.example.backend.global.security.auth.CustomSecurityUtil;
import org.example.backend.global.security.auth.CustomUserDetails;
import org.example.backend.global.userdeviceToken.dto.request.TokenRegisterRequest;
import org.example.backend.global.userdeviceToken.service.UserDeviceTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;

import java.util.UUID;

@RestController
@RequestMapping("/api/push")
@RequiredArgsConstructor
public class UserDeviceTokenController {

private final UserDeviceTokenService tokenService;
private final CustomSecurityUtil customSecurityUtil;

@PostMapping("/register")
public ResponseEntity<String> registerToken(@RequestBody TokenRegisterRequest dto,
@AuthenticationPrincipal CustomUserDetails userDetails) {
UUID userId = customSecurityUtil.getUserId();
tokenService.registerToken(userId, dto.getToken());
return ResponseEntity.ok("Token registered successfully");
}
}
Loading