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
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies {

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

implementation 'org.javers:javers-spring-boot-starter-mongo:7.10.0'
}

//전체 테스트
Expand Down
8 changes: 6 additions & 2 deletions backend/src/main/java/moadong/club/entity/Club.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
import moadong.global.exception.ErrorCode;
import moadong.global.exception.RestApiException;
import org.javers.core.metamodel.annotation.DiffIgnore;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.domain.Persistable;
Expand Down Expand Up @@ -41,6 +42,7 @@ public class Club implements Persistable<String> {

private String userId;

@DiffIgnore
private Map<String, String> socialLinks;

@Field("recruitmentInformation")
Expand Down Expand Up @@ -146,9 +148,11 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus)

public void sendPushNotification(Message message) {
try {
FirebaseMessaging.getInstance().send(message);
log.info("FCM 알림 전송 시작 - clubId: {}, clubName: {}", this.id, this.name);
String messageId = FirebaseMessaging.getInstance().send(message);
log.info("FCM 알림 전송 성공 - clubId: {}, messageId: {}", this.id, messageId);
} catch (FirebaseMessagingException e) {
log.error("FirebaseSendNotificationError: {}", e.getMessage());
log.error("FCM 알림 전송 실패 - clubId: {}, error: {}", this.id, e.getMessage());
Comment on lines 149 to +155
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

FCM 실패 로그에 예외 스택트레이스 포함
현재는 메시지만 남아서 원인 추적이 어렵습니다. 예외 객체를 함께 로깅해 주세요.

🔧 제안 수정
-        } catch (FirebaseMessagingException e) {
-            log.error("FCM 알림 전송 실패 - clubId: {}, error: {}", this.id, e.getMessage());
-        }
+        } catch (FirebaseMessagingException e) {
+            log.error("FCM 알림 전송 실패 - clubId: {}, error: {}", this.id, e.getMessage(), e);
+        }
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/entity/Club.java` around lines 149 - 155,
The FCM failure log in sendPushNotification currently only logs e.getMessage(),
making root-cause tracing hard; update the catch block in the
Club.sendPushNotification method to pass the FirebaseMessagingException object
to the logger (e.g., log.error("FCM 알림 전송 실패 - clubId: {}, error: {}", this.id,
e.getMessage(), e)) so the full stack trace is captured; ensure you keep the
existing message format and import/log usage consistent with the class's logger.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
import moadong.global.RegexConstants;
import org.checkerframework.common.aliasing.qual.Unique;
import org.javers.core.metamodel.annotation.DiffIgnore;

import java.time.Instant;
import java.time.LocalDateTime;
Expand All @@ -26,15 +27,17 @@
@Builder(toBuilder = true)
public class ClubRecruitmentInformation {

@Id
private String id;
// @Id
// private String id;

@Column(length = 1024)
@Unique
@DiffIgnore
private String logo;

@Column(length = 1024)
@Unique
@DiffIgnore
private String cover;

@Column(length = 30)
Expand All @@ -45,6 +48,7 @@ public class ClubRecruitmentInformation {

@Pattern(regexp = RegexConstants.PHONE_NUMBER, message = "전화번호 형식이 올바르지 않습니다.")
@Column(length = 13)
@DiffIgnore
private String presidentTelephoneNumber;

private Instant recruitmentStart;
Expand All @@ -53,16 +57,20 @@ public class ClubRecruitmentInformation {

private String recruitmentTarget;

@DiffIgnore
String externalApplicationUrl;

@DiffIgnore
private List<String> feedImages;

private List<String> tags;

@Enumerated(EnumType.STRING)
@NotNull
@DiffIgnore
private ClubRecruitmentStatus clubRecruitmentStatus;

@DiffIgnore
private LocalDateTime lastModifiedDate;

public void updateLogo(String logo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Optional;
import moadong.club.entity.Club;
import org.bson.types.ObjectId;
import org.javers.spring.annotation.JaversSpringDataAuditable;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import moadong.global.util.ObjectIdConverter;
import moadong.user.payload.CustomUserDetails;
import org.bson.types.ObjectId;
import org.javers.core.Javers;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -23,27 +24,31 @@ public class ClubProfileService {

private final ClubRepository clubRepository;
private final ClubSearchRepository clubSearchRepository;
private final RecruitmentStateCalculator recruitmentStateCalculator;
private final Javers javers;

@Transactional
public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) {
Club club = clubRepository.findClubByUserId(user.getId())
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
club.update(request);
clubRepository.save(club);
Club saved = clubRepository.save(club);
javers.commit(user.getUsername(), saved);
}

public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request,
CustomUserDetails user) {
Club club = clubRepository.findClubByUserId(user.getId())
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
club.update(request);
RecruitmentStateCalculator.calculate(
recruitmentStateCalculator.calculate(
club,
club.getClubRecruitmentInformation().getRecruitmentStart(),
club.getClubRecruitmentInformation().getRecruitmentEnd()
);
club.getClubRecruitmentInformation().updateLastModifiedDate();
clubRepository.save(club);
Club saved = clubRepository.save(club);
javers.commit(user.getUsername(), saved);
}
Comment on lines 39 to 52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the ClubProfileService class to check for transaction annotations
find . -type f -name "ClubProfileService.java" | head -5

Repository: Moadong/moadong

Length of output: 127


🏁 Script executed:

# Also, let's look at the file structure to better understand the project
git ls-files | grep -E "(Service|Repository)" | head -20

Repository: Moadong/moadong

Length of output: 1442


🏁 Script executed:

# Let's check for common transaction management patterns in the codebase
rg "@Transactional" --type java | head -20

Repository: Moadong/moadong

Length of output: 1805


🏁 Script executed:

# Let's examine the ClubProfileService class to see its structure and annotations
cat -n backend/src/main/java/moadong/club/service/ClubProfileService.java

Repository: Moadong/moadong

Length of output: 3211


@transactional 어노테이션 누락으로 인한 데이터 불일치 위험

메서드가 비트랜잭션 상태이기 때문에 clubRepository.save()는 성공하지만 javers.commit()이 실패할 경우, 데이터는 저장되고 API는 오류를 반환하는 불일치가 발생합니다. 같은 클래스의 updateClubInfo 메서드(30행)는 @Transactional이 적용되어 있으므로, 이 메서드에도 동일하게 적용하여 감사 커밋 실패 시 롤백되도록 처리하세요.

🔧 예시(트랜잭션 적용)
-    public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request,
+    `@Transactional`
+    public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request,
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/ClubProfileService.java` around
lines 39 - 52, The updateClubRecruitmentInfo method in ClubProfileService is
missing a transaction boundary, so if clubRepository.save(...) succeeds but
javers.commit(...) fails you get inconsistent state; add the same `@Transactional`
annotation used on updateClubInfo to the updateClubRecruitmentInfo method (or
the class) so the save and javers.commit are executed within a single
transaction and will roll back on failure, ensuring the import and propagation
settings match the existing updateClubInfo usage.


public ClubDetailedResponse getClubDetail(String clubId) {
Expand All @@ -57,3 +62,4 @@ public ClubDetailedResponse getClubDetail(String clubId) {
return new ClubDetailedResponse(clubDetailedResult);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
public class RecruitmentStateChecker {

private final ClubRepository clubRepository;
private final RecruitmentStateCalculator recruitmentStateCalculator;

@Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행
public void performTask() {
Expand All @@ -32,9 +33,10 @@ public void performTask() {
if (recruitInfo.getClubRecruitmentStatus() == ClubRecruitmentStatus.ALWAYS) {
continue;
}
RecruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate);
recruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate);

clubRepository.save(club);
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@

import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import lombok.RequiredArgsConstructor;
import moadong.club.entity.Club;
import moadong.club.entity.ClubRecruitmentInformation;
import moadong.club.enums.ClubRecruitmentStatus;
import moadong.fcm.util.FcmTopicResolver;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RecruitmentStateCalculator {
public static final int ALWAYS_RECRUIT_YEAR = 2999;

public static void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) {
private final FcmTopicResolver fcmTopicResolver;

public void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) {
ClubRecruitmentStatus oldStatus = club.getClubRecruitmentInformation().getClubRecruitmentStatus();
ClubRecruitmentStatus newStatus = calculateRecruitmentStatus(recruitmentStartDate, recruitmentEndDate);
club.updateRecruitmentStatus(newStatus);
Expand Down Expand Up @@ -50,7 +57,7 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec
return ClubRecruitmentStatus.CLOSED;
}

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

Expand All @@ -72,7 +79,8 @@ public static Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus s
.setTitle(club.getName())
.setBody(bodyMessage)
.build())
.setTopic(club.getId())
.setTopic(fcmTopicResolver.resolveTopic(club.getId()))
.build();
}
}

10 changes: 8 additions & 2 deletions backend/src/main/java/moadong/fcm/service/FcmAsyncService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.google.firebase.messaging.TopicManagementResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moadong.fcm.util.FcmTopicResolver;
import moadong.global.exception.ErrorCode;
import moadong.global.exception.RestApiException;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -30,6 +31,8 @@ public class FcmAsyncService {

private final FirebaseMessaging firebaseMessaging;

private final FcmTopicResolver fcmTopicResolver;

@Value("${fcm.topic.timeout-seconds:5}")
private int timeoutSeconds;

Expand All @@ -40,14 +43,16 @@ public CompletableFuture<Void> updateSubscriptions(String token, Set<String> new
// 새로운 동아리 구독
if (!clubsToSubscribe.isEmpty()) {
for (String clubId : clubsToSubscribe) {
futures.add(firebaseMessaging.subscribeToTopicAsync(Collections.singletonList(token), clubId));
String topic = fcmTopicResolver.resolveTopic(clubId);
futures.add(firebaseMessaging.subscribeToTopicAsync(Collections.singletonList(token), topic));
}
}

// 더 이상 구독하지 않는 동아리 구독 해제
if (!clubsToUnsubscribe.isEmpty()) {
for (String clubId : clubsToUnsubscribe) {
futures.add(firebaseMessaging.unsubscribeFromTopicAsync(Collections.singletonList(token), clubId));
String topic = fcmTopicResolver.resolveTopic(clubId);
futures.add(firebaseMessaging.unsubscribeFromTopicAsync(Collections.singletonList(token), topic));
}
}

Expand Down Expand Up @@ -84,3 +89,4 @@ public CompletableFuture<Void> updateSubscriptions(String token, Set<String> new
return CompletableFuture.completedFuture(null);
}
}

18 changes: 18 additions & 0 deletions backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package moadong.fcm.util;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class FcmTopicResolver {

@Value("${spring.profiles.active:prod}")
private String activeProfile;

public String resolveTopic(String clubId) {
if ("prod".equals(activeProfile)) {
return clubId;
}
return activeProfile + "_" + clubId;
Comment on lines +9 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and read the FcmTopicResolver.java file
find . -name "FcmTopicResolver.java" -type f

Repository: Moadong/moadong

Length of output: 121


🏁 Script executed:

# Also check the directory structure to understand the project layout
ls -la backend/src/main/java/moadong/fcm/util/ 2>/dev/null || echo "Path not found, searching..."
find . -type f -name "*.java" -path "*/fcm/util/*" | head -20

Repository: Moadong/moadong

Length of output: 300


🌐 Web query:

Spring spring.profiles.active comma separated multiple profiles behavior

💡 Result:

Short answer: spring.profiles.active accepts a comma-separated list (e.g. --spring.profiles.active=dev,metrics) and activates all listed profiles. Spring will load profile-specific configuration/beans for each active profile; when the same property is defined in multiple active profiles, later entries override earlier ones (i.e. precedence depends on order). Use spring.profiles.include or profile groups to add/compose profiles if needed. [1][2]

Sources:
[1] Spring Boot reference — Profiles and adding active profiles.
[2] Spring Boot blog — config-file processing and profile activation.


🏁 Script executed:

# Read the FcmTopicResolver.java file completely
cat -n ./backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java

Repository: Moadong/moadong

Length of output: 626


🏁 Script executed:

# Search for any other usages of activeProfile or resolveTopic in the codebase
rg "resolveTopic|activeProfile" --type java -B 2 -A 2

Repository: Moadong/moadong

Length of output: 2764


멀티 프로파일 환경에서 토픽 라우팅이 실패합니다.

spring.profiles.activeprod,metrics처럼 콤마로 구분된 여러 프로파일로 설정되면, "prod".equals(activeProfile) 비교가 실패하여 토픽이 잘못 생성됩니다. 예를 들어 "prod,metrics_clubId"처럼 불필요한 접두사가 붙게 되며, 이는 FcmAsyncService와 RecruitmentStateCalculator에서의 Firebase 메시징 라우팅을 방해합니다. 활성 프로파일 목록을 콤마로 분리한 후 prod 포함 여부를 확인하도록 수정하세요.

🔧 제안 수정
     public String resolveTopic(String clubId) {
-        if ("prod".equals(activeProfile)) {
+        String profile = (activeProfile == null || activeProfile.isBlank())
+                ? "prod"
+                : activeProfile;
+        boolean isProd = java.util.Arrays.stream(profile.split(","))
+                .map(String::trim)
+                .anyMatch(p -> p.equalsIgnoreCase("prod"));
+        if (isProd) {
             return clubId;
         }
-        return activeProfile + "_" + clubId;
+        String prefix = profile.split(",")[0].trim();
+        return prefix + "_" + clubId;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Value("${spring.profiles.active:prod}")
private String activeProfile;
public String resolveTopic(String clubId) {
if ("prod".equals(activeProfile)) {
return clubId;
}
return activeProfile + "_" + clubId;
public String resolveTopic(String clubId) {
String profile = (activeProfile == null || activeProfile.isBlank())
? "prod"
: activeProfile;
boolean isProd = java.util.Arrays.stream(profile.split(","))
.map(String::trim)
.anyMatch(p -> p.equalsIgnoreCase("prod"));
if (isProd) {
return clubId;
}
String prefix = profile.split(",")[0].trim();
return prefix + "_" + clubId;
}
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java` around lines 9
- 16, The resolveTopic method in FcmTopicResolver currently compares the entire
activeProfile string to "prod" which breaks when spring.profiles.active contains
multiple comma-separated profiles; change resolveTopic(String clubId) to split
activeProfile by comma, trim entries, check if any entry equals "prod" and if so
return clubId, otherwise use the first non-empty profile entry as the prefix
(profile + "_" + clubId); update references to activeProfile parsing in
FcmTopicResolver.resolveTopic accordingly so Firebase routing in FcmAsyncService
and RecruitmentStateCalculator works correctly.

}
}
27 changes: 27 additions & 0 deletions backend/src/main/java/moadong/global/config/JaversConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package moadong.global.config;

import org.javers.core.Javers;
import org.javers.core.JaversBuilder;
import org.javers.repository.mongo.MongoRepository;
import org.javers.spring.auditable.AuthorProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class JaversConfig {
@Bean
public AuthorProvider authorProvider() {
return () -> {
return SecurityContextHolder.getContext().getAuthentication().getName();
};
Comment on lines +16 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

인증 정보 없을 때 NPE 방지 필요
스케줄러/비인증 컨텍스트에서 getAuthentication()가 null이면 NPE가 발생합니다. 안전한 기본값을 사용하세요.

🔧 제안 수정
     `@Bean`
     public AuthorProvider authorProvider() {
-        return () -> {
-            return SecurityContextHolder.getContext().getAuthentication().getName();
-        };
+        return () -> {
+            var auth = SecurityContextHolder.getContext().getAuthentication();
+            return (auth != null && auth.isAuthenticated()) ? auth.getName() : "system";
+        };
     }
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/global/config/JaversConfig.java` around lines
16 - 19, The authorProvider() lambda can NPE when
SecurityContextHolder.getContext().getAuthentication() is null (e.g., schedulers
or unauthenticated contexts); update authorProvider() to check the
SecurityContext and Authentication for null (and also handle
anonymous/unauthenticated cases) and return a safe default string like "system"
or "anonymous" instead of calling getName() directly; locate the
authorProvider() method in JaversConfig and add the null checks/guarding logic
so it never dereferences a null Authentication.

}

@Bean
public MongoRepository javersMongoRepository(MongoDatabaseFactory dbFactory) {
// MongoDatabaseFactory에서 database 객체를 꺼내서 Javers에 넘김
return new MongoRepository(dbFactory.getMongoDatabase());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package moadong.log.club.controller;

import lombok.RequiredArgsConstructor;
import moadong.log.club.payload.response.ClubHistoryResponse;
import moadong.log.club.service.ClubHistoryService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/clubs")
@RequiredArgsConstructor
public class ClubHistoryController {
private final ClubHistoryService clubHistoryService;

@GetMapping("/{clubId}/histories")
public ResponseEntity<List<ClubHistoryResponse>> getClubHistories(@PathVariable String clubId) {
List<ClubHistoryResponse> histories = clubHistoryService.getClubHistories(clubId);
return ResponseEntity.ok(histories);
}
}
Loading