Skip to content

Commit f64aa55

Browse files
authored
Merge pull request #71 from IT-Cotato/feat/notification
Feat/notification
2 parents af12e03 + c848433 commit f64aa55

File tree

21 files changed

+659
-70
lines changed

21 files changed

+659
-70
lines changed

src/main/java/com/finsight/finsight/domain/auth/domain/service/EmailService.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.finsight.finsight.domain.auth.domain.service;
22

3+
import jakarta.mail.MessagingException;
4+
import jakarta.mail.internet.MimeMessage;
35
import lombok.RequiredArgsConstructor;
46
import org.springframework.mail.SimpleMailMessage;
57
import org.springframework.mail.javamail.JavaMailSender;
8+
import org.springframework.mail.javamail.MimeMessageHelper;
69
import org.springframework.stereotype.Service;
710

811
import java.util.Random;
@@ -24,4 +27,33 @@ public void sendVerificationEmail(String email, String code) {
2427
message.setText("인증번호: " + code + "\n\n3분 내에 입력해주세요.");
2528
mailSender.send(message);
2629
}
27-
}
30+
31+
/**
32+
* 학습 알림 이메일 발송 (평문)
33+
*/
34+
public void sendNotificationEmail(String email, String subject, String content) {
35+
SimpleMailMessage message = new SimpleMailMessage();
36+
message.setTo(email);
37+
message.setSubject(subject);
38+
message.setText(content);
39+
mailSender.send(message);
40+
}
41+
42+
/**
43+
* HTML 이메일 발송
44+
*/
45+
public void sendHtmlEmail(String email, String subject, String htmlContent) {
46+
try {
47+
MimeMessage mimeMessage = mailSender.createMimeMessage();
48+
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
49+
50+
helper.setTo(email);
51+
helper.setSubject(subject);
52+
helper.setText(htmlContent, true); // true = HTML 모드
53+
54+
mailSender.send(mimeMessage);
55+
} catch (MessagingException e) {
56+
throw new RuntimeException("이메일 발송 실패", e);
57+
}
58+
}
59+
}

src/main/java/com/finsight/finsight/domain/auth/presentation/AuthController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ public DataResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest re
6767
@PostMapping("/logout")
6868
@Operation(summary = "로그아웃")
6969
public DataResponse<Void> logout(@AuthenticationPrincipal UserDetails userDetails) {
70+
if (userDetails == null) {
71+
throw new com.finsight.finsight.domain.auth.exception.AuthException(
72+
com.finsight.finsight.domain.auth.exception.code.AuthErrorCode.UNAUTHORIZED);
73+
}
7074
authService.logout(userDetails.getUsername());
7175
return DataResponse.ok();
7276
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.finsight.finsight.domain.mypage.application.dto.request;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
5+
public record UpdateNotificationRequest(
6+
@NotNull(message = "알림 설정 값은 필수입니다.")
7+
Boolean enabled
8+
) {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.finsight.finsight.domain.mypage.application.dto.response;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record NotificationResponse(
7+
Boolean enabled
8+
) {}

src/main/java/com/finsight/finsight/domain/mypage/domain/service/MypageService.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import com.finsight.finsight.domain.category.persistence.repository.UserCategoryOrderRepository;
88
import com.finsight.finsight.domain.category.persistence.repository.UserCategoryRepository;
99
import com.finsight.finsight.domain.mypage.application.dto.request.ChangePasswordRequest;
10+
import com.finsight.finsight.domain.mypage.application.dto.request.UpdateNotificationRequest;
1011
import com.finsight.finsight.domain.mypage.application.dto.request.UpdateProfileRequest;
1112
import com.finsight.finsight.domain.mypage.application.dto.response.LearningReportResponse;
1213
import com.finsight.finsight.domain.mypage.application.dto.response.MypageResponse;
14+
import com.finsight.finsight.domain.mypage.application.dto.response.NotificationResponse;
1315
import com.finsight.finsight.domain.mypage.exception.MypageException;
1416
import com.finsight.finsight.domain.mypage.exception.code.MypageErrorCode;
1517
import com.finsight.finsight.domain.mypage.persistence.mapper.MypageConverter;
@@ -315,4 +317,28 @@ private String getDayOfWeekKorean(DayOfWeek dayOfWeek) {
315317
case SUNDAY -> "일";
316318
};
317319
}
318-
}
320+
321+
/**
322+
* 알림 설정 조회
323+
*/
324+
@Transactional(readOnly = true)
325+
public NotificationResponse getNotificationSetting(Long userId) {
326+
UserEntity user = userRepository.findById(userId)
327+
.orElseThrow(() -> new MypageException(MypageErrorCode.MEMBER_NOT_FOUND));
328+
329+
return NotificationResponse.builder()
330+
.enabled(user.getNotificationEnabled())
331+
.build();
332+
}
333+
334+
/**
335+
* 알림 설정 변경
336+
*/
337+
@Transactional
338+
public void updateNotificationSetting(Long userId, UpdateNotificationRequest request) {
339+
UserEntity user = userRepository.findById(userId)
340+
.orElseThrow(() -> new MypageException(MypageErrorCode.MEMBER_NOT_FOUND));
341+
342+
user.updateNotificationEnabled(request.enabled());
343+
}
344+
}

src/main/java/com/finsight/finsight/domain/mypage/presentation/MypageController.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import com.finsight.finsight.domain.auth.application.dto.request.CheckNicknameRequest;
44
import com.finsight.finsight.domain.mypage.application.dto.request.ChangePasswordRequest;
5+
import com.finsight.finsight.domain.mypage.application.dto.request.UpdateNotificationRequest;
56
import com.finsight.finsight.domain.mypage.application.dto.request.UpdateProfileRequest;
67
import com.finsight.finsight.domain.mypage.application.dto.response.LearningReportResponse;
78
import com.finsight.finsight.domain.mypage.application.dto.response.MypageResponse;
9+
import com.finsight.finsight.domain.mypage.application.dto.response.NotificationResponse;
810
import com.finsight.finsight.domain.mypage.domain.service.MypageService;
911
import com.finsight.finsight.domain.mypage.exception.MypageException;
1012
import com.finsight.finsight.domain.mypage.exception.code.MypageErrorCode;
@@ -141,4 +143,35 @@ public ResponseEntity<DataResponse<Void>> changePassword(
141143
myPageService.changePassword(customUserDetails.getUserId(), request);
142144
return ResponseEntity.ok(DataResponse.ok());
143145
}
146+
147+
@Operation(summary = "알림 설정 조회", description = "알림 ON/OFF 상태를 조회합니다.")
148+
@ApiResponses({
149+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "알림 설정 조회 성공")
150+
})
151+
@GetMapping("/me/notification")
152+
public ResponseEntity<DataResponse<NotificationResponse>> getNotificationSetting(
153+
@AuthenticationPrincipal CustomUserDetails customUserDetails
154+
) {
155+
if (customUserDetails == null) {
156+
throw new MypageException(MypageErrorCode.UNAUTHORIZED_ACCESS);
157+
}
158+
return ResponseEntity.ok(
159+
DataResponse.from(myPageService.getNotificationSetting(customUserDetails.getUserId())));
160+
}
161+
162+
@Operation(summary = "알림 설정 변경", description = "알림 ON/OFF 상태를 변경합니다.")
163+
@ApiResponses({
164+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "알림 설정 변경 성공")
165+
})
166+
@PutMapping("/me/notification")
167+
public ResponseEntity<DataResponse<Void>> updateNotificationSetting(
168+
@AuthenticationPrincipal CustomUserDetails customUserDetails,
169+
@Valid @RequestBody UpdateNotificationRequest request
170+
) {
171+
if (customUserDetails == null) {
172+
throw new MypageException(MypageErrorCode.UNAUTHORIZED_ACCESS);
173+
}
174+
myPageService.updateNotificationSetting(customUserDetails.getUserId(), request);
175+
return ResponseEntity.ok(DataResponse.ok());
176+
}
144177
}

src/main/java/com/finsight/finsight/domain/naver/persistence/repository/UserArticleViewRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,13 @@ List<Object[]> findCategoryBalanceByWeek(
4848
void deleteByUserUserId(Long userId);
4949

5050
boolean existsByUserUserIdAndArticleId(Long userId, Long articleId);
51+
52+
/**
53+
* 날짜 범위 내 조회한 뉴스 수
54+
*/
55+
@Query("SELECT COUNT(uav) FROM UserArticleViewEntity uav WHERE uav.user.userId = :userId AND uav.viewedAt BETWEEN :start AND :end")
56+
Long countByUserIdAndViewedAtBetween(
57+
@Param("userId") Long userId,
58+
@Param("start") LocalDateTime start,
59+
@Param("end") LocalDateTime end);
5160
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.finsight.finsight.domain.notification.application.usecase;
2+
3+
import com.finsight.finsight.domain.notification.domain.service.NotificationService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class NotificationScheduler {
11+
12+
private final NotificationService notificationService;
13+
14+
/**
15+
* 매일 오전 8시 일일 알림 발송
16+
*/
17+
@Scheduled(cron = "0 0 8 * * *")
18+
public void sendDailyNotification() {
19+
notificationService.sendDailyNotifications();
20+
}
21+
22+
/**
23+
* 매주 월요일 오전 9시 주간 알림 발송
24+
*/
25+
@Scheduled(cron = "0 0 9 * * MON")
26+
public void sendWeeklyNotification() {
27+
notificationService.sendWeeklyNotifications();
28+
}
29+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.finsight.finsight.domain.notification.domain.service;
2+
3+
import com.finsight.finsight.domain.auth.domain.service.EmailService;
4+
import com.finsight.finsight.domain.naver.persistence.repository.UserArticleViewRepository;
5+
import com.finsight.finsight.domain.notification.infrastructure.template.NotificationTemplateBuilder;
6+
import com.finsight.finsight.domain.quiz.persistence.repository.QuizAttemptRepository;
7+
import com.finsight.finsight.domain.storage.persistence.entity.FolderType;
8+
import com.finsight.finsight.domain.storage.persistence.repository.FolderItemRepository;
9+
import com.finsight.finsight.domain.user.domain.constant.AuthType;
10+
import com.finsight.finsight.domain.user.persistence.entity.UserAuthEntity;
11+
import com.finsight.finsight.domain.user.persistence.entity.UserEntity;
12+
import com.finsight.finsight.domain.user.persistence.repository.UserRepository;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
import java.time.DayOfWeek;
18+
import java.time.LocalDate;
19+
import java.time.LocalDateTime;
20+
import java.util.List;
21+
22+
@Service
23+
@RequiredArgsConstructor
24+
public class NotificationService {
25+
26+
private final UserRepository userRepository;
27+
private final FolderItemRepository folderItemRepository;
28+
private final QuizAttemptRepository quizAttemptRepository;
29+
private final UserArticleViewRepository userArticleViewRepository;
30+
private final EmailService emailService;
31+
private final NotificationTemplateBuilder templateBuilder;
32+
33+
/**
34+
* 일일 알림 발송 (매일 오전 8시)
35+
* 어제 학습 현황 기준
36+
*/
37+
@Transactional(readOnly = true)
38+
public void sendDailyNotifications() {
39+
List<UserEntity> users = userRepository.findByNotificationEnabledAndAuthType(AuthType.EMAIL);
40+
41+
LocalDateTime yesterdayStart = LocalDate.now().minusDays(1).atStartOfDay();
42+
LocalDateTime yesterdayEnd = LocalDate.now().atStartOfDay();
43+
44+
for (UserEntity user : users) {
45+
try {
46+
String email = getEmailFromUser(user);
47+
if (email == null) continue;
48+
49+
boolean isNewsSaved = folderItemRepository.existsByUserIdAndItemTypeAndSavedAtBetween(
50+
user.getUserId(), FolderType.NEWS, yesterdayStart, yesterdayEnd);
51+
boolean isQuizSolved = quizAttemptRepository.existsNewAttemptBetween(
52+
user.getUserId(), yesterdayStart, yesterdayEnd);
53+
boolean isQuizReviewed = quizAttemptRepository.existsReviewAttemptBetween(
54+
user.getUserId(), yesterdayStart, yesterdayEnd);
55+
56+
String htmlContent = templateBuilder.buildDailyEmail(isNewsSaved, isQuizSolved, isQuizReviewed);
57+
emailService.sendHtmlEmail(email, "[FinSight] 오늘의 학습 알림", htmlContent);
58+
} catch (Exception e) {
59+
// 개별 유저 실패해도 다음 유저 진행
60+
}
61+
}
62+
}
63+
64+
/**
65+
* 주간 알림 발송 (매주 월요일 오전 9시)
66+
* 지난주 월~일 학습 현황 기준
67+
*/
68+
@Transactional(readOnly = true)
69+
public void sendWeeklyNotifications() {
70+
List<UserEntity> users = userRepository.findByNotificationEnabledAndAuthType(AuthType.EMAIL);
71+
72+
LocalDateTime lastWeekStart = LocalDate.now().minusWeeks(1).with(DayOfWeek.MONDAY).atStartOfDay();
73+
LocalDateTime lastWeekEnd = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay();
74+
75+
for (UserEntity user : users) {
76+
try {
77+
String email = getEmailFromUser(user);
78+
if (email == null) continue;
79+
80+
Long quizCount = quizAttemptRepository.countByUserIdAndCreatedAtBetween(
81+
user.getUserId(), lastWeekStart, lastWeekEnd);
82+
Long newsCount = userArticleViewRepository.countByUserIdAndViewedAtBetween(
83+
user.getUserId(), lastWeekStart, lastWeekEnd);
84+
85+
String htmlContent = templateBuilder.buildWeeklyEmail(
86+
quizCount != null ? quizCount : 0,
87+
newsCount != null ? newsCount : 0);
88+
emailService.sendHtmlEmail(email, "[FinSight] 주간 학습 리포트", htmlContent);
89+
} catch (Exception e) {
90+
// 개별 유저 실패해도 다음 유저 진행
91+
}
92+
}
93+
}
94+
95+
private String getEmailFromUser(UserEntity user) {
96+
return user.getUserAuths().stream()
97+
.filter(auth -> auth.getAuthType() == AuthType.EMAIL)
98+
.map(UserAuthEntity::getIdentifier)
99+
.findFirst()
100+
.orElse(null);
101+
}
102+
}

0 commit comments

Comments
 (0)