Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
73ec7d8
feature: add fcm token save function
lepitaaar Oct 5, 2025
d3458b4
feature: fcm 토큰이 존재할 시 timestamp 업데이트 기능 추가
lepitaaar Oct 5, 2025
6e20c57
feature: add 구독중인 clubIds 목록 필드 추가
lepitaaar Oct 7, 2025
2cf911c
feature: add 구독중인 clubIds 업데이트 메서드
lepitaaar Oct 7, 2025
8e96f3e
feature: Optinal 타입 변경
lepitaaar Oct 7, 2025
a63af93
feature: 토큰 Not Found 에러 추가
lepitaaar Oct 7, 2025
ac5807e
feature: subscribe Post, Get 메서드 추가
lepitaaar Oct 7, 2025
68193a4
refactor: 메서드 이름 구분 및 request param으로 token 을 받음
lepitaaar Oct 7, 2025
bea0088
chore: add gitignore firebase.json
lepitaaar Oct 7, 2025
0a335a0
chore: add firebase sdk dependency
lepitaaar Oct 7, 2025
8a26c4a
feature: add fcm initializer
lepitaaar Oct 7, 2025
1d7fcc5
refactor: fcm 초기화가 되지않을시 어플리케이션 실행 안되게 변경
lepitaaar Oct 7, 2025
2bb510b
refactor: 잘못된 스케쥴링 시간 주석 변경
lepitaaar Oct 7, 2025
16ece0a
feature: 동아리 fcm topic 구독 추가
lepitaaar Oct 7, 2025
e31414e
feature: 동아리 모집 상태 변경시 알림 전송
lepitaaar Oct 7, 2025
110cce5
fix: classpath로 빌드 후 resource 파일 가져올 수 있게 수정
lepitaaar Oct 10, 2025
e5fc874
feature: 동아리 지원상태가 변경될때 이전상태와 다를경우 알람 전송
lepitaaar Oct 10, 2025
1b14ff6
feature: 구독 요청의 clubIds 목록이 존재하는 동아리인지 검증
lepitaaar Oct 11, 2025
1209811
feature: firebase 구독요청 비동기
lepitaaar Oct 11, 2025
d95a64d
feature: 동아리 검증 로직을 Set으로 변경하여 시간복잡도를 O(N+M)으로 줄임
lepitaaar Oct 11, 2025
86083c3
refactor: List 로 자료형 변경
lepitaaar Oct 11, 2025
fcf21e1
refactor: List 로 자료형 변경
lepitaaar Oct 11, 2025
446263d
feature: 블로킹 로직을 Async로 이동
lepitaaar Oct 11, 2025
537e21a
refactor: 테스용 print to log로 변경
lepitaaar Oct 11, 2025
62db43f
feature: 재시도 기능 추가
lepitaaar Oct 11, 2025
c47d023
chore: retry 의존성 추가
lepitaaar Oct 11, 2025
ad79e11
refactor: swagger api 설명 추가
lepitaaar Oct 11, 2025
89638c7
feature: test코드 작성
lepitaaar Oct 12, 2025
261d7ad
refactor: 넓은 범위 Exception handle 축소
lepitaaar Oct 12, 2025
9404757
refactor: 중복 코드 삭제
lepitaaar Oct 12, 2025
56050fc
refactor: Timeout 추가
lepitaaar Oct 12, 2025
2e92dbf
refactor: not registered 토큰 제거 로직 변경
lepitaaar Oct 12, 2025
740327a
refactor: 네트워킹 작업과 디비 트랜잭션 서비스 분리
lepitaaar Oct 12, 2025
049ad63
refactor: 비동기 메서드 응답을 CompletableFuture<Void>로 변경하여 호출 로직에서 에러 로깅하게 변경
lepitaaar Oct 12, 2025
fc582fb
feature: firebaseMessage 인스턴스 빈 주입
lepitaaar Oct 12, 2025
77d0a30
refactor: UnRegistered 토큰 구독 시 발생하는 오류 Mockito로 성공처리
lepitaaar Oct 12, 2025
1ce0fde
refactor: dirty 체킹안되는 코드 변경
lepitaaar Oct 12, 2025
dd81a4b
refactor: cors 추가
lepitaaar Oct 12, 2025
613db2a
refactor: POST to PUT 으로 메서드 변경
lepitaaar Oct 12, 2025
ac4c3b3
refactor: async thread pool 크기 제한
lepitaaar Oct 12, 2025
8b60afc
refactor: logging message 변경
lepitaaar Oct 12, 2025
011d418
chore: pr test에 firebase.json 삽입 추가
lepitaaar Oct 27, 2025
0ac6739
test: pr test 용 로깅 try catch
lepitaaar Oct 27, 2025
b32b1cc
chore: secret 값 base64 디코딩으로 읽기
lepitaaar Oct 27, 2025
539f874
fix: 테스트 실패 오류 수정
lepitaaar Oct 30, 2025
63813ea
refactor: option stream close 추가 및 초기화 실패시 오류 발생
lepitaaar Oct 31, 2025
12712e6
refactor: 반환 값 null 대신 CompletableFuture.completedFuture(null) 변경
lepitaaar Oct 31, 2025
6180ca0
refactor: 토큰이 존재할 때만 삭제 로그 작성
lepitaaar Oct 31, 2025
5f687e3
test: 등록되지않은 토큰을 삭제한다 테스트 추가
lepitaaar Oct 31, 2025
f5d8a62
fix: 엔티티클래스 private 접근자 추가
lepitaaar Oct 31, 2025
aaf068e
fix: 조건문 조건 오류 수정
lepitaaar Nov 1, 2025
0e15534
refactor: 알림전송 책임 분리
lepitaaar Nov 2, 2025
245b438
fix: Mockito로 생성된 club 엔티티의 id를 가져올 수 있게 변경
lepitaaar Nov 6, 2025
3284cc8
refactor: FCM 구독중 예외 발생시 예외를 throw 하지않고 로깅만함
lepitaaar Nov 6, 2025
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
5 changes: 5 additions & 0 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ jobs:
run: |
cd ./backend/src/main/resources
echo "${{ secrets.APPLICATION_PR_TEST }}" > ./application.properties

- name: make firebase.json
run: |
cd ./backend/src/main/resources
echo "${{ secrets.FIREBASE_PR_TEST }}" | base64 -d > ./firebase.json

- name: create-json
id: create-json
Expand Down
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ out/

application.properties
moadong.json
firebase.json
4 changes: 3 additions & 1 deletion backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.retry:spring-retry'
implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
Expand All @@ -56,7 +58,7 @@ dependencies {
implementation 'net.coobird:thumbnailator:0.4.14'
implementation 'org.springframework:spring-test'


implementation 'com.google.firebase:firebase-admin:9.7.0'

}

Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/java/moadong/MoadongApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@RequiredArgsConstructor
@EnableScheduling
@EnableRetry
public class MoadongApplication {

public static void main(String[] args) {
Expand Down
17 changes: 17 additions & 0 deletions backend/src/main/java/moadong/club/entity/Club.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package moadong.club.entity;

import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import moadong.club.enums.ClubRecruitmentStatus;
import moadong.club.enums.ClubState;
import moadong.club.payload.request.ClubInfoRequest;
Expand All @@ -17,6 +25,7 @@
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;

@Slf4j
@Document("clubs")
@AllArgsConstructor
@Getter
Expand Down Expand Up @@ -119,6 +128,14 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus)
this.clubRecruitmentInformation.updateRecruitmentStatus(clubRecruitmentStatus);
}

public void sendPushNotification(Message message) {
try {
FirebaseMessaging.getInstance().send(message);
} catch (FirebaseMessagingException e) {
log.error("FirebaseSendNotificationError: {}", e.getMessage());
}
}

@Override
public boolean isNew() {
return this.version == null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface ClubRepository extends MongoRepository<Club, String> {

Optional<Club> findClubByUserId(String userId);
List<Club> findAllByName(List<String> clubs);

Long countByIdIn(List<String> id);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package moadong.club.service;

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moadong.club.entity.Club;
import moadong.club.entity.ClubRecruitmentInformation;
import moadong.club.enums.ClubRecruitmentStatus;
Expand All @@ -12,14 +20,15 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "scheduling.enabled", havingValue = "true", matchIfMissing = true)
public class RecruitmentStateChecker {

private final ClubRepository clubRepository;

@Scheduled(fixedRate = 60 * 60 * 1000) // 5분마다 실행
@Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행
public void performTask() {
List<Club> clubs = clubRepository.findAll();
for (Club club : clubs) {
Expand All @@ -30,6 +39,7 @@ public void performTask() {
continue;
}
RecruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate);

clubRepository.save(club);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,75 @@

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Locale;

import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import moadong.club.entity.Club;
import moadong.club.entity.ClubRecruitmentInformation;
import moadong.club.enums.ClubRecruitmentStatus;

public class RecruitmentStateCalculator {
public static final int ALWAYS_RECRUIT_YEAR = 2999;

public static void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) {
ClubRecruitmentStatus newStatus = calculateRecruitmentStatus(recruitmentStartDate, recruitmentEndDate);
club.updateRecruitmentStatus(newStatus);

Message message = buildRecruitmentMessage(club, newStatus);
club.sendPushNotification(message);
}

public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) {
if (recruitmentStartDate == null || recruitmentEndDate == null) {
club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED);
return;
return ClubRecruitmentStatus.CLOSED;
}

if (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) {
club.updateRecruitmentStatus(ClubRecruitmentStatus.ALWAYS);
return;
return ClubRecruitmentStatus.ALWAYS;
}
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));

ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));

if (now.isBefore(recruitmentStartDate)) {
long between = ChronoUnit.DAYS.between(recruitmentStartDate, now);
if (between <= 14) {
club.updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING);
} else {
club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED);
}
} else if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) {
club.updateRecruitmentStatus(ClubRecruitmentStatus.OPEN);
} else if (now.isAfter(recruitmentEndDate)) {
club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED);
long daysUntilStart = ChronoUnit.DAYS.between(now, recruitmentStartDate);
return (daysUntilStart <= 14)
? ClubRecruitmentStatus.UPCOMING
: ClubRecruitmentStatus.CLOSED;
}

if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) {
return ClubRecruitmentStatus.OPEN;
}

return ClubRecruitmentStatus.CLOSED;
}

public static Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M월 d일 a h시 m분", Locale.KOREAN);
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();

String bodyMessage = switch (status) {
case ALWAYS -> "상시 모집 중입니다. 언제든지 지원해주세요!";
case OPEN -> {
String formattedEndTime = info.getRecruitmentEnd().format(formatter);
yield formattedEndTime + "까지 모집 중이니 서둘러 지원하세요!";
}
case UPCOMING -> {
String formattedStartTime = info.getRecruitmentStart().format(formatter);
yield formattedStartTime + "부터 모집이 시작될 예정이에요. 조금만 기다려주세요!";
}
case CLOSED -> "모집이 마감되었습니다. 다음 모집을 기대해주세요.";
};

return Message.builder()
.setNotification(Notification.builder()
.setTitle(club.getName())
.setBody(bodyMessage)
.build())
.setTopic(club.getId())
.build();
}
}
42 changes: 42 additions & 0 deletions backend/src/main/java/moadong/fcm/controller/FcmController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package moadong.fcm.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import moadong.fcm.payload.request.ClubSubscribeRequest;
import moadong.fcm.payload.request.FcmSaveRequest;
import moadong.fcm.service.FcmService;
import moadong.global.payload.Response;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/fcm")
@AllArgsConstructor
@Tag(name = "FCM", description = "FCM 토큰 관리 및 알림 전송 기능 API")
public class FcmController {
private final FcmService fcmService;

@Operation(summary = "FCM 토큰 저장", description = "FCM 토큰을 서버에 저장합니다.")
@PostMapping
public ResponseEntity<?> saveFcmToken(@RequestBody @Validated FcmSaveRequest request) {
fcmService.saveFcmToken(request.fcmToken());
return Response.ok("success save fcm token");
}

@Operation(summary = "동아리 모집정보 알림 구독", description = "특정 동아리들의 모집 정보가 변경되면저거 하면 알림을 받도록 구독합니다.")
@PutMapping("/subscribe")
public ResponseEntity<?> subscribeRecruitment(@RequestBody @Validated ClubSubscribeRequest request) {
fcmService.subscribeClubs(request.fcmToken(), request.clubIds());
return Response.ok("success subscribe club");
}

@Operation(summary = "구독한 동아리 목록 조회", description = "FCM 토큰을 기준으로 구독중인 동아리 목록을 조회합니다.")
@GetMapping("/subscribe")
public ResponseEntity<?> getSubscribedClubs(@RequestParam("fcmToken")
@Validated @NotNull String fcmToken) {
return Response.ok(fcmService.getSubscribeClubs(fcmToken));
}
}
40 changes: 40 additions & 0 deletions backend/src/main/java/moadong/fcm/entity/FcmToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package moadong.fcm.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

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

@Document("fcm_tokens")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class FcmToken {

@Id
private String id;

private String token;

@Builder.Default
private List<String> clubIds = new ArrayList<>();

@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();

public void updateTimestamp() {
this.timestamp = LocalDateTime.now();
}

public void updateClubIds(List<String> clubIds) {
this.clubIds.clear();
this.clubIds.addAll(clubIds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package moadong.fcm.payload.request;

import jakarta.validation.constraints.NotNull;

import java.util.ArrayList;

public record ClubSubscribeRequest(
@NotNull
String fcmToken,
@NotNull
ArrayList<String> clubIds
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package moadong.fcm.payload.request;

import jakarta.validation.constraints.NotNull;

public record FcmSaveRequest(
@NotNull
String fcmToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package moadong.fcm.payload.response;

import java.util.List;

public record ClubSubscribeListResponse(
List<String> clubIds
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package moadong.fcm.repository;

import moadong.fcm.entity.FcmToken;
import org.springframework.data.mongodb.repository.MongoRepository;

import java.util.Optional;

public interface FcmTokenRepository extends MongoRepository<FcmToken, String> {
Optional<FcmToken> findFcmTokenByToken(String fcmToken);
}
Loading
Loading