Skip to content

feat : 시즌 알림 기능 (#81) #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 7, 2025
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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ dependencies {
// Spring Security OAuth2
implementation ("org.springframework.security:spring-security-oauth2-client:6.4.2") // Or the version you're using
implementation ("org.springframework.security:spring-security-oauth2-core:6.4.2") // Or the version you're using

implementation("org.springframework.boot:spring-boot-starter-actuator")

}

tasks.withType<Test> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import cmf.commitField.global.error.ErrorCode;
import cmf.commitField.global.exception.CustomException;
import cmf.commitField.global.globalDto.GlobalResponse;
import cmf.commitField.global.websocket.NotiWebSocketHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
Expand All @@ -28,6 +29,7 @@
public class ApiV1NotiController {
private final NotiService notiService;
private final UserRepository userRepository;
private final NotiWebSocketHandler notiWebSocketHandler;

@GetMapping("")
public GlobalResponse<List<NotiDto>> getNoti() {
Expand All @@ -39,6 +41,9 @@ public GlobalResponse<List<NotiDto>> getNoti() {
String username = (String) attributes.get("login"); // GitHub ID
User user = userRepository.findByUsername(username).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
List<NotiDto> notis = notiService.getNotReadNoti(user);
// 웹소켓으로 알림 전송
notiWebSocketHandler.sendNotification(user, notis);

return GlobalResponse.success(notis);
}

Expand All @@ -49,4 +54,19 @@ public GlobalResponse<List<NotiDto>> getNoti() {
public void createNoti() {

}
}

@PostMapping("/read")
public GlobalResponse<Object> readNoti() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication instanceof OAuth2AuthenticationToken) {
OAuth2User principal = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = principal.getAttributes();
String username = (String) attributes.get("login"); // GitHub ID
User user = userRepository.findByUsername(username).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
notiService.read(user);
return GlobalResponse.success("알림을 읽음 처리했습니다.");
}
return GlobalResponse.error(ErrorCode.LOGIN_REQUIRED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cmf.commitField.domain.noti.noti.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

@Getter
public class NotiEvent extends ApplicationEvent {
private final String username;
private final String message;

public NotiEvent(Object source, String username, String message) {
super(source);
this.username = username;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package cmf.commitField.domain.noti.noti.event;

import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class NotiListener {
@EventListener
public void handleNotiEvent(NotiEvent event) {
System.out.println("NotiEvent: " + event.getMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ public interface NotiRepository extends JpaRepository<Noti, Long> {
@Query("SELECT new cmf.commitField.domain.noti.noti.dto.NotiDto(n.id, n.message, n.createdAt) " +
"FROM Noti n JOIN n.receiver u WHERE u.id = :receiverId AND n.isRead = :isRead")
Optional<List<NotiDto>> findNotiDtoByReceiverId(@Param("receiverId") Long receiverId, @Param("isRead") boolean isRead);

}
Optional<List<Noti>> findNotiByReceiver(User receiver);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,35 @@ public List<Noti> getSeasonNotiCheck(User receiver, long seasonId) {

// 새 시즌 알림 생성
@Transactional
public void createNewSeason(Season season) {
public void createNewSeasonNoti(Season season, User user) {
System.out.println("새 시즌 알림 생성");
// 메시지 생성
String message = NotiService.generateMessage(NotiDetailType.SEASON_START, season.getName());

// 모든 사용자 조회
Iterable<User> users = userRepository.findAll();
Noti noti = Noti.builder()
.typeCode(NotiType.SEASON)
.type2Code(NotiDetailType.SEASON_START)
.receiver(user)
.isRead(false)
.message(message)
.relId(season.getId())
.relTypeCode(season.getModelName())
.build();

// 모든 유저 알림 객체 생성
users.forEach(user -> {
Noti noti = Noti.builder()
.typeCode(NotiType.SEASON)
.type2Code(NotiDetailType.SEASON_START)
.receiver(user)
.isRead(false)
.message(message)
.relId(season.getId())
.relTypeCode(season.getModelName())
.build();
notiRepository.save(noti);

notiRepository.save(noti);
});
System.out.println("새 시즌 알림 생성 끝");
}
}

// 읽음 처리
@Transactional
public List<Noti> read(User receiver) {
System.out.println("알림 읽음 처리");
List<Noti> notis = notiRepository.findNotiByReceiver(receiver).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
notis.forEach(noti -> {
noti.setRead(true);
});
System.out.println("알림 읽음 처리 끝");
return notis;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) {
if(notiService.getSeasonNotiCheck(user, season.getId()).isEmpty()){
log.info("User {} does not have season noti", user.getUsername());
// 가지고 있지 않다면 알림을 추가
notiService.createNewSeason(season);
notiService.createNewSeasonNoti(season, user);
// redisTemplate.opsForValue().set(season_key, String.valueOf(count), Duration.ofHours(3)); // 3시간 캐싱
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
package cmf.commitField.global.scheduler;

import cmf.commitField.domain.noti.noti.service.NotiService;
import cmf.commitField.domain.user.entity.User;
import cmf.commitField.domain.user.repository.UserRepository;
import cmf.commitField.global.error.ErrorCode;
import cmf.commitField.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class NotiTestScheduler {
private final NotiService notiService;
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;

// @Scheduled(cron = "0 44 * * * *")
// public void test() {
// System.out.println("test 실행");
// Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//
// if (authentication instanceof OAuth2AuthenticationToken) {
// OAuth2User principal = (OAuth2User) authentication.getPrincipal();
// Map<String, Object> attributes = principal.getAttributes();
// String username = (String) attributes.get("login"); // GitHub ID
// User user = userRepository.findByUsername(username).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
// notiService.createNoti(user);
// }
//
// }
@Scheduled(cron = "30 14 * * * *")
public void test() {
System.out.println("test 실행");

User user = userRepository.findById(1L).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
notiService.createNoti(user);
// eventPublisher.publishEvent();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// java/cmf/commitField/global/scheduler/SeasonScheduler.java
package cmf.commitField.global.scheduler;

import cmf.commitField.domain.noti.noti.service.NotiService;
import cmf.commitField.domain.season.entity.Rank;
import cmf.commitField.domain.season.entity.Season;
import cmf.commitField.domain.season.entity.SeasonStatus;
Expand Down Expand Up @@ -29,6 +30,7 @@ public class SeasonScheduler {
private final UserSeasonRepository userSeasonRepository;
private final UserRepository userRepository;
private final SeasonService seasonService;
private final NotiService notiService;

// 매년 3, 6, 9, 12월 1일 자정마다 시즌 확인 및 생성
@Scheduled(cron = "0 0 0 1 3,6,9,12 *")
Expand All @@ -53,6 +55,11 @@ public void checkAndCreateNewSeason() {

Season newSeason = seasonService.createNewSeason(seasonName, startDate, endDate);

// 모든 유저에게 새 시즌 알림 생성
userRepository.findAll().forEach(user -> {
notiService.createNewSeasonNoti(newSeason, user);
});

// 모든 유저의 랭크 초기화
resetUserRanks(newSeason);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,82 @@
package cmf.commitField.global.websocket;

import cmf.commitField.domain.noti.noti.dto.NotiDto;
import cmf.commitField.domain.noti.noti.entity.Noti;
import cmf.commitField.domain.noti.noti.service.NotiService;
import cmf.commitField.domain.user.entity.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;

import java.io.IOException;
import java.util.ArrayList;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
@RequiredArgsConstructor
@Slf4j
public class NotiWebSocketHandler implements WebSocketHandler {

private final List<WebSocketSession> sessions = new ArrayList<>();
private final NotiService notiService;
private final ObjectMapper objectMapper;
private final Map<Long, WebSocketSession> sessions = new ConcurrentHashMap<>();

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session);
log.info("알림 WebSocket 연결됨: " + session);
log.info("클라이언트 접속: {}", session.getId());

// 연결 성공 메시지 전송
Map<String, Object> connectMessage = new HashMap<>();
connectMessage.put("type", "SYSTEM");
connectMessage.put("connect", "알림 서버에 연결되었습니다.");
connectMessage.put("timestamp", LocalDateTime.now().toString());

try {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(connectMessage)));
} catch (Exception e) {
log.error("연결 메시지 전송 실패: {}", e.getMessage());
}
}

@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
// 알림 메시지 처리 로직 (필요 시 구현)
if (message instanceof TextMessage) {
String payload = ((TextMessage) message).getPayload();
log.info("Received message: {}", payload);
} else {
log.warn("Received unsupported message type: {}", message.getClass().getSimpleName());
}
}

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("알림 WebSocket 오류: " + exception.getMessage());
log.error("WebSocket error: ", exception);
session.close(CloseStatus.SERVER_ERROR);
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
sessions.remove(session);
log.info("알림 WebSocket 연결 종료됨: " + session);
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
sessions.values().remove(session);
log.info("WebSocket disconnected: {}", status);
}

@Override
public boolean supportsPartialMessages() {
return false;
}

// 모든 유저에게 알림 메시지 전송
public void sendNotificationToAllUsers(String message) {
for (WebSocketSession session : sessions) {
public void sendNotification(User receiver, List<NotiDto> noti) {
WebSocketSession session = sessions.get(receiver.getId());
if (session != null && session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
String payload = objectMapper.writeValueAsString(noti);
session.sendMessage(new TextMessage(payload));
} catch (IOException e) {
log.error("알림 메시지 전송 실패: " + e.getMessage());
log.error("Failed to send WebSocket notification", e);
}
}
}
Expand Down