Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0146241
chore : FCM 의존성 추가
Daae-Kim Dec 27, 2025
8d45b9c
remove : fcmEntity 삭제
Daae-Kim Dec 29, 2025
887c07e
feat : memberPushTokenEntity, model 생성
Daae-Kim Dec 29, 2025
c0e3f7d
feat : 알림 provider enum 생성, 제공자 NotFound 예외 추가
Daae-Kim Dec 29, 2025
1073813
feat : 알림토큰 삭제 권한, not found 예외 작성
Daae-Kim Jan 4, 2026
2afc1cc
feat : request dto, controller, swagger 구현
Daae-Kim Jan 4, 2026
4f5f7ff
feat : usecase, model, converter, service 구현
Daae-Kim Jan 4, 2026
8ddd568
feat : entity, repository, jpaRepository 구현
Daae-Kim Jan 4, 2026
aa4fcd5
chore : notification api security 체인 등록
Daae-Kim Jan 4, 2026
b7c8b39
chore : spotless apply
Daae-Kim Jan 4, 2026
82642b7
refactor : NotBlank 사용
Daae-Kim Jan 4, 2026
4b61f34
refactor : 불필요한 component 삭제
Daae-Kim Jan 4, 2026
bb95894
refactor : status, 에러메세지 수정
Daae-Kim Jan 4, 2026
8890a5f
refactor : unique 제약조건 추가
Daae-Kim Jan 4, 2026
412629b
refactor : 패키지 오타 수정
Daae-Kim Jan 4, 2026
ae39fe3
refactor : 패키지 오타 수정
Daae-Kim Jan 4, 2026
df6033f
refactor : sl4j 삭제, delete 반환값 void 변경
Daae-Kim Jan 4, 2026
711cc76
chore : spotlessApply
Daae-Kim Jan 4, 2026
99acb17
feat : lastActiveAt 필드 추가
Daae-Kim Jan 5, 2026
ee5b4d7
feat : 토큰 생성 로직 수정
Daae-Kim Jan 5, 2026
f568b5e
chore : spotlessApply
Daae-Kim Jan 5, 2026
a6a5df2
refactor : 중복 토큰 조회 메서드 수정
Daae-Kim Jan 5, 2026
17bafe1
chore : spotlessApply
Daae-Kim Jan 5, 2026
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
4 changes: 4 additions & 0 deletions eeos/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ dependencies {

// OpenFeign
implementation(libs.spring.cloud.starter.openfeign)

// Firebase Admin SDK
implementation(libs.firebase.admin)

}

dependencyManagement {
Expand Down
4 changes: 4 additions & 0 deletions eeos/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ spotless = "7.2.1"
asciidoctor = "4.0.2"
epages-restdocs = "0.18.2"
swagger = "5.10.3"
firebase-admin = "9.2.0"

# Security
jwt = "0.12.6"
Expand Down Expand Up @@ -57,6 +58,9 @@ jbcrypt = { group = "org.mindrot", name = "jbcrypt", version.ref = "jbcrypt" }
# Lombok
lombok = { group = "org.projectlombok", name = "lombok" }

# Firebase Admin SDK
firebase-admin = { group = "com.google.firebase", name = "firebase-admin", version.ref = "firebase-admin" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ SecurityFilterChain authenticated(HttpSecurity httpSecurity) throws Exception {
.requestMatchers("/api/admin/**")
.requestMatchers("/api/team-building/**")
.requestMatchers("/api/semester-periods/**")
.requestMatchers("/api/calendars/**");
.requestMatchers("/api/calendars/**")
.requestMatchers("/api/pushToken", "/api/pushToken/**");
});

httpSecurity.authorizeHttpRequests(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.blackcompany.eeos.notification.application.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder(toBuilder = true)
public class CreateMemberPushTokenRequest {
@NotBlank private String pushToken;
@NotBlank private String provider;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.blackcompany.eeos.notification.application.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder(toBuilder = true)
public class DeleteMemberPushTokenRequest {
@NotBlank private String pushToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.blackcompany.eeos.notification.application.exception;

import com.blackcompany.eeos.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class DeniedDeletePushTokenException extends BusinessException {
private static final String FAIL_CODE = "5008";

public DeniedDeletePushTokenException() {
super(FAIL_CODE, HttpStatus.FORBIDDEN);
}

@Override
public String getMessage() {
return "토큰 수정 권한이 없습니다.";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.blackcompany.eeos.notification.application.exception;

import com.blackcompany.eeos.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class NotFoundNotificationProviderException extends BusinessException {

private static final String FAIL_CODE = "5006";
private final String provider;

public NotFoundNotificationProviderException(String provider) {
super(FAIL_CODE, HttpStatus.NOT_FOUND);
this.provider = provider;
}

@Override
public String getMessage() {
return String.format("%s 는 존재하지 않는 외부 제공자 입니다.", provider);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.blackcompany.eeos.notification.application.exception;

import com.blackcompany.eeos.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class NotFoundPushTokenException extends BusinessException {

private static final String FAIL_CODE = "5007";

public NotFoundPushTokenException() {

super(FAIL_CODE, HttpStatus.NOT_FOUND);
}

@Override
public String getMessage() {
return "존재하지 않는 알림토큰 입니다.";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.blackcompany.eeos.notification.application.model;

import com.blackcompany.eeos.common.support.AbstractModel;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
public class MemberPushTokenModel implements AbstractModel {
private Long id;
private Long memberId;
private NotificationProvider notificationProvider;
private String pushToken;
private LocalDateTime lastActiveAt;

public MemberPushTokenModel renew(Long memberId) {
return this.toBuilder().memberId(memberId).lastActiveAt(LocalDateTime.now()).build();
}
}
Comment on lines +14 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private LocalDateTime lastActiveAt 은 왜 필요한가요?

Copy link
Member Author

@Daae-Kim Daae-Kim Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말이 길어졌습니다..ㅎ
요약 : 토큰 신선도 관리를 위해서 도입했고, 토큰 사용시마다 lastActiveAt 값을 현재시간으로 변경, 저장하기위해 도입했습니다!

알림 토큰의 경우 생성시 타임스탬프를 기록해서 신선도 관리가 필요합니다.
기존에 base entity의 update_date를 사용하려고 했었는데 동일한 토큰에 대해서 post 요청이 오는 상황이 있어요 (로그인 -> fcm에서 이전 발급한 토큰을 그대로 반환 -> db 에 저장된것과 동일한 토큰 post 요청 및 사용 날짜 기록)

이 경우에 마지막으로 사용한 날짜를 update_date 로 하면 jpa dirty checking 에 걸리지 않을 수도 있겠다 생각했습니다 (기존 저장된 토큰과 비교해서 변경사항이 없기 때문)

이때문에 lastActiveAt 이라는 필드를 만들게 되었고, 사용자가 로그인 후 기존 알림 토큰을 사용할때 lastActiveAt 값이 현재시간으로 업데이트되어 마지막으로 토큰을 사용한 시간을 기록하도록 구현했습니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.blackcompany.eeos.notification.application.model;

import com.blackcompany.eeos.notification.application.exception.NotFoundNotificationProviderException;
import java.util.Arrays;
import lombok.Getter;

@Getter
public enum NotificationProvider {
FCM("fcm"),
ETC("etc");

private final String provider;

NotificationProvider(String provider) {
this.provider = provider;
}

public static NotificationProvider find(String name) {
return Arrays.stream(NotificationProvider.values())
.filter(provider -> provider.name().equalsIgnoreCase(name))
.findFirst()
.orElseThrow(() -> new NotFoundNotificationProviderException(name));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.blackcompany.eeos.notification.application.model.converter;

import com.blackcompany.eeos.common.support.converter.AbstractEntityConverter;
import com.blackcompany.eeos.notification.application.model.MemberPushTokenModel;
import com.blackcompany.eeos.notification.persistence.MemberPushTokenEntity;
import org.springframework.stereotype.Component;

@Component
public class MemberPushTokenEntityConverter
implements AbstractEntityConverter<MemberPushTokenEntity, MemberPushTokenModel> {

@Override
public MemberPushTokenModel from(MemberPushTokenEntity source) {
return MemberPushTokenModel.builder()
.id(source.getId())
.memberId(source.getMemberId())
.notificationProvider(source.getProvider())
.pushToken(source.getPushToken())
.lastActiveAt(source.getLastActiveAt())
.build();
}

@Override
public MemberPushTokenEntity toEntity(MemberPushTokenModel source) {
return MemberPushTokenEntity.builder()
.id(source.getId())
.memberId(source.getMemberId())
.pushToken(source.getPushToken())
.provider(source.getNotificationProvider())
.lastActiveAt(source.getLastActiveAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.blackcompany.eeos.notification.application.repository;

import com.blackcompany.eeos.notification.application.model.MemberPushTokenModel;
import com.blackcompany.eeos.notification.application.model.NotificationProvider;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface MemberPushTokenRepository {
Optional<MemberPushTokenModel> findByPushToken(String pushToken);

List<MemberPushTokenModel> findByMemberId(Long memberId);

List<MemberPushTokenModel> findByMemberIdAndProvider(
Long memberId, NotificationProvider provider);

void deleteByPushToken(String pushToken);

void deleteByMemberId(Long memberId);

int deleteByUpdatedDateBefore(LocalDateTime updatedDate);

MemberPushTokenModel save(MemberPushTokenModel memberPushToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.blackcompany.eeos.notification.application.service;

import com.blackcompany.eeos.notification.application.dto.CreateMemberPushTokenRequest;
import com.blackcompany.eeos.notification.application.dto.DeleteMemberPushTokenRequest;
import com.blackcompany.eeos.notification.application.exception.DeniedDeletePushTokenException;
import com.blackcompany.eeos.notification.application.exception.NotFoundPushTokenException;
import com.blackcompany.eeos.notification.application.model.MemberPushTokenModel;
import com.blackcompany.eeos.notification.application.model.NotificationProvider;
import com.blackcompany.eeos.notification.application.repository.MemberPushTokenRepository;
import com.blackcompany.eeos.notification.application.usecase.CreateMemberPushTokenUsecase;
import com.blackcompany.eeos.notification.application.usecase.DeleteAllMemberPushTokensUsecase;
import com.blackcompany.eeos.notification.application.usecase.DeleteMemberPushTokenUsecase;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NotificationTokenService
implements CreateMemberPushTokenUsecase,
DeleteAllMemberPushTokensUsecase,
DeleteMemberPushTokenUsecase {

private final MemberPushTokenRepository memberPushTokenRepository;

@Override
@Transactional
public void create(Long memberId, CreateMemberPushTokenRequest request) {
NotificationProvider provider = NotificationProvider.find(request.getProvider());

MemberPushTokenModel model =
memberPushTokenRepository
.findByPushToken(request.getPushToken())
.map(existingModel -> existingModel.renew(memberId))
.orElseGet(
() ->
MemberPushTokenModel.builder()
.memberId(memberId)
.pushToken(request.getPushToken())
.notificationProvider(provider)
.lastActiveAt(LocalDateTime.now())
.build());

memberPushTokenRepository.save(model);
}

@Override
@Transactional
public void deleteAllMemberPushTokens(Long memberId) {
memberPushTokenRepository.deleteByMemberId(memberId);
}

@Override
@Transactional
public void delete(Long memberId, DeleteMemberPushTokenRequest request) {
MemberPushTokenModel model =
memberPushTokenRepository
.findByPushToken(request.getPushToken())
.orElseThrow(NotFoundPushTokenException::new);
if (!model.getMemberId().equals(memberId)) {
throw new DeniedDeletePushTokenException();
}
memberPushTokenRepository.deleteByPushToken(request.getPushToken());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.blackcompany.eeos.notification.application.usecase;

import com.blackcompany.eeos.notification.application.dto.CreateMemberPushTokenRequest;

public interface CreateMemberPushTokenUsecase {
void create(Long memberId, CreateMemberPushTokenRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.blackcompany.eeos.notification.application.usecase;

public interface DeleteAllMemberPushTokensUsecase {
void deleteAllMemberPushTokens(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.blackcompany.eeos.notification.application.usecase;

import com.blackcompany.eeos.notification.application.dto.DeleteMemberPushTokenRequest;

public interface DeleteMemberPushTokenUsecase {
void delete(Long memberId, DeleteMemberPushTokenRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.blackcompany.eeos.notification.persistence;

import com.blackcompany.eeos.notification.application.model.NotificationProvider;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface JpaMemberPushTokenRepository extends JpaRepository<MemberPushTokenEntity, Long> {

Optional<MemberPushTokenEntity> findByPushToken(String token);

List<MemberPushTokenEntity> findByMemberId(Long memberId);

List<MemberPushTokenEntity> findByMemberIdAndProvider(
Long memberId, NotificationProvider provider);

@Modifying
@Query("DELETE FROM MemberPushTokenEntity t WHERE t.memberId = :memberId")
void deleteByMemberId(@Param("memberId") Long memberId);

void deleteByPushToken(String token);

int deleteByUpdatedDateBefore(Timestamp updatedDate);
}
Loading