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
4 changes: 4 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jobs:
mkdir -p src/main/resources/static
echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8

- name: Set Firebase Credentials
run: |
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json

- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ jobs:
mkdir -p src/main/resources/static
echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8

- name: Set Firebase Credentials
run: |
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json

- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ jobs:
mkdir -p src/main/resources/static
echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8

- name: Set Firebase Credentials
run: |
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ src/main/resources/static/AuthKey.p8
src/main/resources/application-aws.yml

### Claude Code ###
.claude/
.claude/

### Firebase ###
src/main/resources/firebase.json
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-configuration-processor'
implementation 'software.amazon.awssdk:s3:2.31.16'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.google.firebase:firebase-admin:9.3.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/kr/swyp/backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableJpaAuditing
@EnableFeignClients
@EnableScheduling
@SpringBootApplication
@ConfigurationPropertiesScan
public class BackendApplication {
Expand Down
63 changes: 63 additions & 0 deletions src/main/java/kr/swyp/backend/common/config/FcmConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package kr.swyp.backend.common.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class FcmConfig {

private final FcmProperties fcmProperties;
private final Environment environment;

@PostConstruct
public void initialize() {
if (!fcmProperties.isEnabled()) {
log.info("FCM is disabled. Skipping initialization.");
return;
}

// 테스트 프로파일에서는 초기화 건너뛰기
String[] activeProfiles = environment.getActiveProfiles();
for (String profile : activeProfiles) {
if ("test".equals(profile)) {
log.info("Test profile detected. Skipping FCM initialization.");
return;
}
}
Comment on lines +32 to +38

Choose a reason for hiding this comment

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

medium

for 루프를 사용하여 활성 프로필을 확인하는 대신, java.util.Arrays.streamanyMatch를 사용하면 코드를 더 간결하게 작성할 수 있습니다.

        if (java.util.Arrays.stream(environment.getActiveProfiles()).anyMatch("test"::equals)) {
            log.info("Test profile detected. Skipping FCM initialization.");
            return;
        }


try {
if (FirebaseApp.getApps().isEmpty()) {
String credentialsPath = fcmProperties.getCredentialsPath();

// classpath 또는 파일 시스템 경로 지원
try (InputStream serviceAccount = credentialsPath.startsWith("classpath:")
? new ClassPathResource(
credentialsPath.substring("classpath:".length())).getInputStream()
: new FileInputStream(credentialsPath)) {

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

FirebaseApp.initializeApp(options);
log.info("Firebase application has been initialized successfully.");
}
}
} catch (IOException e) {
log.error("Failed to initialize Firebase application.", e);
throw new RuntimeException("Failed to initialize FCM", e);
}
}
}
16 changes: 16 additions & 0 deletions src/main/java/kr/swyp/backend/common/config/FcmProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.swyp.backend.common.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "swyp.fcm")
public class FcmProperties {

private String credentialsPath;
private boolean enabled = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ public ResponseEntity<ErrorInfo> handleIllegalArgumentException(
return responseException("BAD_REQUEST", e.getMessage(), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorInfo> handleIllegalStateException(
IllegalStateException e) {
log.info("잘못된 상태: {}", e.getMessage());
return responseException("INVALID_STATE", e.getMessage(), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(MethodNotAllowedException.class)
public ResponseEntity<ErrorInfo> handleMethodNotAllowedException(
MethodNotAllowedException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ List<FriendAnniversary> findAllByFriendIdIsInAndDateBetween(List<UUID> friendIds
""")
List<FriendAnniversary> findAllAnniversaryByCheckingLogIdList(
@Param("checkingLogIdList") List<Long> checkingLogIdList);

/**
* 오늘 날짜(월-일)의 기념일을 가진 항목들 조회 (알림 대상).
* 연도는 무시하고 월-일만 비교.
*/
@Query("""
SELECT fa
FROM FriendAnniversary fa
WHERE MONTH(fa.date) = :month AND DAY(fa.date) = :day
""")
List<FriendAnniversary> findAllByMonthAndDay(@Param("month") int month, @Param("day") int day);
Comment on lines +37 to +42

Choose a reason for hiding this comment

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

medium

WHERE 절에서 MONTH(fa.date)DAY(fa.date) 같은 함수를 사용하면 date 컬럼의 인덱스를 활용하지 못하고 풀 테이블 스캔(full table scan)을 유발할 수 있습니다. FriendAnniversary 테이블의 데이터가 많아질 경우 성능 저하의 원인이 될 수 있습니다. 성능 개선이 필요하다면, 월(month)과 일(day)을 별도의 컬럼으로 저장하고 인덱스를 생성하는 방법을 고려해볼 수 있습니다.

}
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,10 @@ List<Friend> findAllByMemberIdWithCheckedLogsInPeriod(

void deleteAllByMemberId(UUID memberId);

/**
* 특정 날짜에 연락해야 할 친구 목록 조회 (알림 대상).
*/
List<Friend> findAllByNextContactAt(LocalDate targetDate);

}

8 changes: 8 additions & 0 deletions src/main/java/kr/swyp/backend/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ public class Member extends BaseEntity implements UserDetails {
@Column(name = "WITHDRAWN_AT")
private LocalDateTime withdrawnAt;

@Comment("FCM 토큰")
@Column(name = "FCM_TOKEN")
private String fcmToken;

@Default
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER,
cascade = CascadeType.ALL, orphanRemoval = true)
Expand Down Expand Up @@ -125,4 +129,8 @@ public void reactivate() {
this.withdrawnAt = null;
this.isActive = true;
}

public void updateFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public class AppPushMessagingServiceImpl implements AppPushMessagingService {
@Override
@Transactional
public void registerDevice(UUID memberId, RegisterAppPushTokenRequest request) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다."));

Optional<AppPushToken> maybeAppPushToken = appPushTokenRepository.findByMemberId(memberId);

if (maybeAppPushToken.isEmpty()) {
Expand All @@ -34,16 +37,17 @@ public void registerDevice(UUID memberId, RegisterAppPushTokenRequest request) {
.build();
appPushTokenRepository.save(appPushToken);

Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다."));
member.updateNotificationAgreedAt(LocalDateTime.now());
memberRepository.save(member);
} else {
// 항목이 존재하면 토큰을 업데이트한다.
AppPushToken appPushToken = maybeAppPushToken.get();
appPushToken.updateToken(request.getToken(), request.getOsType());
appPushTokenRepository.save(appPushToken);
}

// Member 엔티티의 fcmToken도 동기화 (NotificationScheduler에서 사용)
member.updateFcmToken(request.getToken());
memberRepository.save(member);
}

@Override
Expand All @@ -54,5 +58,7 @@ public void unregisterDevice(UUID memberId, UnregisterAppPushTokenRequest reques
.orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다."));

member.updateNotificationAgreedAt(null);
member.updateFcmToken(null);
memberRepository.save(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package kr.swyp.backend.notification.controller;

import jakarta.validation.Valid;
import java.util.Map;
import kr.swyp.backend.member.dto.MemberDetails;
import kr.swyp.backend.notification.dto.ForceSendNotificationRequest;
import kr.swyp.backend.notification.dto.SendTestNotificationRequest;
import kr.swyp.backend.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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;

@RestController
@RequestMapping("/notifications")
@PreAuthorize("hasAnyAuthority('USER')")
@RequiredArgsConstructor
public class NotificationController {

private final NotificationService notificationService;

/**
* 임시 테스트용 FCM 알림 전송 엔드포인트
* JWT 토큰으로 인증된 사용자에게 FCM 푸시 알림을 전송합니다.
*/
@PostMapping("/test/send")
public ResponseEntity<Map<String, String>> sendTestNotification(
@AuthenticationPrincipal MemberDetails memberDetails,
@Valid @RequestBody SendTestNotificationRequest request) {

notificationService.sendTestNotificationToMember(
memberDetails.getMemberId(),
request.getTitle(),
request.getBody()
);

return ResponseEntity.ok(Map.of(
"message", "FCM 알림이 성공적으로 전송되었습니다.",
"memberId", memberDetails.getMemberId().toString()
));
}

/**
* 특정 사용자에게 강제로 FCM 알림을 전송하는 엔드포인트 (관리자용).
* memberId를 직접 지정하여 해당 사용자에게 푸시 알림을 전송합니다.
*/
@PostMapping("/force/send")

Choose a reason for hiding this comment

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

critical

/force/send 엔드포인트는 특정 사용자에게 강제로 알림을 보낼 수 있는 강력한 기능이지만, 현재 아무런 인증/인가 제어가 없어 누구나 이 API를 호출할 수 있는 심각한 보안 취약점이 존재합니다. 관리자만 이 기능을 사용할 수 있도록 접근을 제한해야 합니다. 예를 들어, Spring Security의 메서드 시큐리티를 활성화(@EnableMethodSecurity)하고 해당 메서드에 @PreAuthorize("hasRole('ADMIN')") 어노테이션을 추가하는 방법을 사용할 수 있습니다.

    @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
    @PostMapping("/force/send")

public ResponseEntity<Map<String, String>> forceSendNotification(
@Valid @RequestBody ForceSendNotificationRequest request) {

notificationService.forceSendNotification(
request.getMemberId(),
request.getTitle(),
request.getBody(),
request.getFriendId()
);

return ResponseEntity.ok(Map.of(
"message", "FCM 알림이 성공적으로 전송되었습니다.",
"memberId", request.getMemberId().toString()
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.swyp.backend.notification.dto;

import java.util.Map;
import java.util.UUID;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class FcmNotificationRequest {

private String fcmToken;
private String title;
private String body;
private Map<String, String> data;
private UUID friendId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.swyp.backend.notification.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ForceSendNotificationRequest {

@NotNull(message = "회원 ID는 필수입니다")
private UUID memberId;

@NotBlank(message = "제목은 필수입니다")
private String title;

@NotBlank(message = "내용은 필수입니다")
private String body;

private UUID friendId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.swyp.backend.notification.dto;

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

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SendTestNotificationRequest {

@NotBlank(message = "제목은 필수입니다")
private String title;

@NotBlank(message = "내용은 필수입니다")
private String body;
}
Loading