From 3d463736fc09c5ab8e407ae4de3b33b99327a2f3 Mon Sep 17 00:00:00 2001 From: Zepelown Date: Wed, 14 Jan 2026 22:26:00 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Javers=20=EC=85=8B=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 + .../main/java/moadong/club/entity/Club.java | 2 ++ .../entity/ClubRecruitmentInformation.java | 12 +++++++-- .../club/repository/ClubRepository.java | 1 + .../club/service/ClubProfileService.java | 8 ++++-- .../moadong/global/config/JaversConfig.java | 27 +++++++++++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/moadong/global/config/JaversConfig.java diff --git a/backend/build.gradle b/backend/build.gradle index d07cb9834..3f5b94130 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation 'com.google.firebase:firebase-admin:9.7.0' + implementation 'org.javers:javers-spring-boot-starter-mongo:7.10.0' } //전체 테스트 diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 4e81a5333..ec42c2714 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -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; @@ -41,6 +42,7 @@ public class Club implements Persistable { private String userId; + @DiffIgnore private Map socialLinks; @Field("recruitmentInformation") diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index 819df38e2..104fe8ba2 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -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; @@ -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) @@ -45,6 +48,7 @@ public class ClubRecruitmentInformation { @Pattern(regexp = RegexConstants.PHONE_NUMBER, message = "전화번호 형식이 올바르지 않습니다.") @Column(length = 13) + @DiffIgnore private String presidentTelephoneNumber; private Instant recruitmentStart; @@ -53,16 +57,20 @@ public class ClubRecruitmentInformation { private String recruitmentTarget; + @DiffIgnore String externalApplicationUrl; + @DiffIgnore private List feedImages; private List tags; @Enumerated(EnumType.STRING) @NotNull + @DiffIgnore private ClubRecruitmentStatus clubRecruitmentStatus; + @DiffIgnore private LocalDateTime lastModifiedDate; public void updateLogo(String logo) { diff --git a/backend/src/main/java/moadong/club/repository/ClubRepository.java b/backend/src/main/java/moadong/club/repository/ClubRepository.java index ab40067bf..2b961dd45 100644 --- a/backend/src/main/java/moadong/club/repository/ClubRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubRepository.java @@ -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; diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 5116ecba9..7bfea21cc 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -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; @@ -23,13 +24,15 @@ public class ClubProfileService { private final ClubRepository clubRepository; private final ClubSearchRepository clubSearchRepository; + 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, @@ -43,7 +46,8 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, club.getClubRecruitmentInformation().getRecruitmentEnd() ); club.getClubRecruitmentInformation().updateLastModifiedDate(); - clubRepository.save(club); + Club saved = clubRepository.save(club); + javers.commit(user.getUsername(), saved); } public ClubDetailedResponse getClubDetail(String clubId) { diff --git a/backend/src/main/java/moadong/global/config/JaversConfig.java b/backend/src/main/java/moadong/global/config/JaversConfig.java new file mode 100644 index 000000000..32c7e918a --- /dev/null +++ b/backend/src/main/java/moadong/global/config/JaversConfig.java @@ -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(); + }; + } + + @Bean + public MongoRepository javersMongoRepository(MongoDatabaseFactory dbFactory) { + // MongoDatabaseFactory에서 database 객체를 꺼내서 Javers에 넘김 + return new MongoRepository(dbFactory.getMongoDatabase()); + } +} From 6924014a41a2f4042fa7d5da92d5d672016b731c Mon Sep 17 00:00:00 2001 From: Zepelown Date: Wed, 14 Jan 2026 22:46:48 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ClubHistoryController.java | 25 ++++++ .../payload/response/ClubHistoryResponse.java | 79 +++++++++++++++++++ .../log/club/service/ClubHistoryService.java | 40 ++++++++++ 3 files changed, 144 insertions(+) create mode 100644 backend/src/main/java/moadong/log/club/controller/ClubHistoryController.java create mode 100644 backend/src/main/java/moadong/log/club/payload/response/ClubHistoryResponse.java create mode 100644 backend/src/main/java/moadong/log/club/service/ClubHistoryService.java diff --git a/backend/src/main/java/moadong/log/club/controller/ClubHistoryController.java b/backend/src/main/java/moadong/log/club/controller/ClubHistoryController.java new file mode 100644 index 000000000..bbebe2862 --- /dev/null +++ b/backend/src/main/java/moadong/log/club/controller/ClubHistoryController.java @@ -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> getClubHistories(@PathVariable String clubId) { + List histories = clubHistoryService.getClubHistories(clubId); + return ResponseEntity.ok(histories); + } +} diff --git a/backend/src/main/java/moadong/log/club/payload/response/ClubHistoryResponse.java b/backend/src/main/java/moadong/log/club/payload/response/ClubHistoryResponse.java new file mode 100644 index 000000000..ff9d9d548 --- /dev/null +++ b/backend/src/main/java/moadong/log/club/payload/response/ClubHistoryResponse.java @@ -0,0 +1,79 @@ +package moadong.log.club.payload.response; + +import lombok.Builder; +import lombok.Getter; +import moadong.club.entity.Club; +import moadong.club.enums.ClubRecruitmentStatus; +import org.javers.shadow.Shadow; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Getter +@Builder +public class ClubHistoryResponse { + // 1. 메타데이터 (버전 정보) + private long version; + private LocalDateTime modifiedAt; + private String modifiedBy; + + // 2. Club 엔티티 주요 데이터 + private String name; + private String division; + private String category; + private String state; // Enum -> String 변환 권장 + + // 3. ClubRecruitmentInformation 주요 데이터 (DiffIgnore 제외된 것들) + private String introduction; + private String presidentName; + private String recruitmentTarget; + private ClubRecruitmentStatus recruitmentStatus; + private LocalDateTime recruitmentStart; + private LocalDateTime recruitmentEnd; + + // tags는 DiffIgnore가 없으므로 포함 + private List tags; + + // 4. 안전한 변환 메서드 (Factory Method) + public static ClubHistoryResponse from(Shadow shadow) { + Club club = shadow.get(); + var metadata = shadow.getCommitMetadata(); + + // 중첩 객체 Null 방어 로직 + var recruitmentInfo = club.getClubRecruitmentInformation(); + boolean hasInfo = recruitmentInfo != null; + + return ClubHistoryResponse.builder() + .version(metadata.getId().getMajorId()) + .modifiedAt(metadata.getCommitDate()) + .modifiedBy(metadata.getAuthor()) + + // Club 필드 매핑 + .name(club.getName()) + .division(club.getDivision()) + .category(club.getCategory()) + .state(club.getState() != null ? club.getState().name() : null) + + // RecruitmentInfo 필드 매핑 (Null Safe) + .introduction(hasInfo ? recruitmentInfo.getIntroduction() : null) + .presidentName(hasInfo ? recruitmentInfo.getPresidentName() : null) + .recruitmentTarget(hasInfo ? recruitmentInfo.getRecruitmentTarget() : null) + .recruitmentStatus(hasInfo ? recruitmentInfo.getClubRecruitmentStatus() : null) + + // 시간 타입 변환 (Instant -> LocalDateTime 등 필요시) + .recruitmentStart(hasInfo && recruitmentInfo.getRecruitmentStart() != null + ? recruitmentInfo.getRecruitmentStart().toLocalDateTime() + : null) + .recruitmentEnd(hasInfo && recruitmentInfo.getRecruitmentEnd() != null + ? recruitmentInfo.getRecruitmentEnd().toLocalDateTime() + : null) + + // 리스트 필드 Null 방어 + .tags(hasInfo && recruitmentInfo.getTags() != null + ? recruitmentInfo.getTags() + : Collections.emptyList()) + + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/log/club/service/ClubHistoryService.java b/backend/src/main/java/moadong/log/club/service/ClubHistoryService.java new file mode 100644 index 000000000..56e2c1060 --- /dev/null +++ b/backend/src/main/java/moadong/log/club/service/ClubHistoryService.java @@ -0,0 +1,40 @@ +package moadong.log.club.service; + +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.log.club.payload.response.ClubHistoryResponse; +import org.javers.core.Javers; +import org.javers.repository.jql.JqlQuery; +import org.javers.repository.jql.QueryBuilder; +import org.javers.shadow.Shadow; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ClubHistoryService { + + private final Javers javers; + + public List getClubHistories(String clubId) { + JqlQuery query = QueryBuilder.byInstanceId(clubId, Club.class) + .withChildValueObjects() + .limit(20) + .build(); + + List> shadows = findClubShadows(query); + + return shadows.stream() + .map(ClubHistoryResponse::from) + .toList(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private List> findClubShadows(JqlQuery query) { + return (List) javers.findShadows(query); + } + +} \ No newline at end of file From 3382f542ffe34d456ed372ee58b3cf1eb55a3f0d Mon Sep 17 00:00:00 2001 From: Zepelown Date: Mon, 19 Jan 2026 21:58:34 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20ClubFixture=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=98=EB=AF=B8=EC=97=86=EC=96=B4=EC=A7=84=20ClubRecruitment?= =?UTF-8?q?Information=20Id=20=EC=83=9D=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/test/java/moadong/fixture/ClubFixture.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/test/java/moadong/fixture/ClubFixture.java b/backend/src/test/java/moadong/fixture/ClubFixture.java index 0049a3bba..2c828dffc 100644 --- a/backend/src/test/java/moadong/fixture/ClubFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubFixture.java @@ -30,7 +30,7 @@ public static ClubRecruitmentInformation createRecruitmentInfo( List feedImages, ClubRecruitmentStatus clubRecruitmentStatus) { ClubRecruitmentInformation clubRecruitmentInfo = mock(ClubRecruitmentInformation.class); - when(clubRecruitmentInfo.getId()).thenReturn(id); +// when(clubRecruitmentInfo.getId()).thenReturn(id); when(clubRecruitmentInfo.getLogo()).thenReturn(logo); when(clubRecruitmentInfo.getIntroduction()).thenReturn(introduction); when(clubRecruitmentInfo.getPresidentName()).thenReturn(presidentName); From 5746e9d6158bb68fe827c110c9027d16ae18f79d Mon Sep 17 00:00:00 2001 From: Zepelown Date: Mon, 19 Jan 2026 22:02:16 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20javers=20stub=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/club/service/ClubProfileServiceDateTest.java | 4 ++++ .../test/java/moadong/unit/club/ClubProfileServiceTest.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index 5650b77bd..17b13218a 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -9,6 +9,7 @@ import moadong.fixture.UserFixture; import moadong.user.payload.CustomUserDetails; import moadong.util.annotations.UnitTest; +import org.javers.core.Javers; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,6 +42,9 @@ public class ClubProfileServiceDateTest { @Mock ClubSearchRepository clubSearchRepository; + @Mock + Javers javers; + @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") @Test void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ diff --git a/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java b/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java index 0d253c0c9..c90a6f134 100644 --- a/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java +++ b/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java @@ -16,6 +16,7 @@ import moadong.global.exception.RestApiException; import moadong.user.payload.CustomUserDetails; import moadong.util.annotations.UnitTest; +import org.javers.core.Javers; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -29,6 +30,9 @@ public class ClubProfileServiceTest { @InjectMocks private ClubProfileService clubProfileService; + @Mock + Javers javers; + @Test void 정상적으로_클럽_약력을_업데이트한다() { // Given From 63d4f337ca1211252f329578b8273ccfa630eed8 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 19:52:50 -0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=95/=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20FCM=20?= =?UTF-8?q?=ED=86=A0=ED=94=BD=20=EB=B6=84=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=ED=99=98=EA=B2=BD=EB=B3=84=20prefix=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FcmTopicResolver 생성하여 spring.profiles.active 기반 토픽 prefix 생성 - RecruitmentStateCalculator를 @Component로 리팩터링하여 FcmTopicResolver 주입 - FcmAsyncService 구독/구독해제 시 prefixed 토픽 사용하도록 수정 - application.properties에 기본 profile을 local로 설정 토픽 네이밍: - prod: clubId (기존 호환성 유지) - staging: staging_clubId - local: local_clubId --- .../club/service/ClubProfileService.java | 4 +- .../club/service/RecruitmentStateChecker.java | 4 +- .../club/util/RecruitmentStateCalculator.java | 14 +++++-- .../moadong/fcm/service/FcmAsyncService.java | 10 ++++- .../moadong/fcm/util/FcmTopicResolver.java | 18 +++++++++ .../service/ClubProfileServiceDateTest.java | 38 ++++++++----------- 6 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 5116ecba9..19ddbb18c 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -23,6 +23,7 @@ public class ClubProfileService { private final ClubRepository clubRepository; private final ClubSearchRepository clubSearchRepository; + private final RecruitmentStateCalculator recruitmentStateCalculator; @Transactional public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { @@ -37,7 +38,7 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, 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() @@ -57,3 +58,4 @@ public ClubDetailedResponse getClubDetail(String clubId) { return new ClubDetailedResponse(clubDetailedResult); } } + diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java index 151616fbb..9f53a0690 100644 --- a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -21,6 +21,7 @@ public class RecruitmentStateChecker { private final ClubRepository clubRepository; + private final RecruitmentStateCalculator recruitmentStateCalculator; @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 public void performTask() { @@ -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); } } } + diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index 8e4b89210..d8f295060 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -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); @@ -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(); @@ -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(); } } + diff --git a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java index 064fa51c7..404e6562a 100644 --- a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java +++ b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java @@ -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; @@ -30,6 +31,8 @@ public class FcmAsyncService { private final FirebaseMessaging firebaseMessaging; + private final FcmTopicResolver fcmTopicResolver; + @Value("${fcm.topic.timeout-seconds:5}") private int timeoutSeconds; @@ -40,14 +43,16 @@ public CompletableFuture updateSubscriptions(String token, Set 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)); } } @@ -84,3 +89,4 @@ public CompletableFuture updateSubscriptions(String token, Set new return CompletableFuture.completedFuture(null); } } + diff --git a/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java b/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java new file mode 100644 index 000000000..81781913e --- /dev/null +++ b/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java @@ -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; + } +} diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index 5650b77bd..d92858230 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -14,8 +14,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -26,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -41,6 +40,9 @@ public class ClubProfileServiceDateTest { @Mock ClubSearchRepository clubSearchRepository; + @Mock + RecruitmentStateCalculator recruitmentStateCalculator; + @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") @Test void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ @@ -49,27 +51,19 @@ public class ClubProfileServiceDateTest { CustomUserDetails customUserDetails = UserFixture.createUserDetails("test"); Club club = new Club(); when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(club)); - //updateClubRecruitmentInfo의 RecruitmentStateCalculator 무시 - try (var mocked = Mockito.mockStatic(RecruitmentStateCalculator.class)) { - mocked.when(() -> - RecruitmentStateCalculator.calculate( - Mockito.any(moadong.club.entity.Club.class), - Mockito.any(java.time.ZonedDateTime.class), - Mockito.any(java.time.ZonedDateTime.class) - ) - ).thenAnswer(inv -> null); + doNothing().when(recruitmentStateCalculator).calculate(any(), any(), any()); - //WHEN - clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); + //WHEN + clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); - //THEN - assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); - //1초 전후 차이로 살펴보기 - LocalDateTime now = LocalDateTime.now(); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isAfter(now.minusSeconds(1))); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isBefore(now.plusSeconds(1))); - } + //THEN + assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); + //1초 전후 차이로 살펴보기 + LocalDateTime now = LocalDateTime.now(); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isAfter(now.minusSeconds(1))); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isBefore(now.plusSeconds(1))); } } + From bd7f557257d3d2ddbadadfd37ad8c3c896c5b471 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 19:54:43 -0800 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 4e81a5333..1b7f46cbe 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -146,9 +146,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()); } } From 5a4a806fbf3d9d50a276f79d487a8ef2fb76d519 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 19:52:50 -0800 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=95/=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20FCM=20?= =?UTF-8?q?=ED=86=A0=ED=94=BD=20=EB=B6=84=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=ED=99=98=EA=B2=BD=EB=B3=84=20prefix=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FcmTopicResolver 생성하여 spring.profiles.active 기반 토픽 prefix 생성 - RecruitmentStateCalculator를 @Component로 리팩터링하여 FcmTopicResolver 주입 - FcmAsyncService 구독/구독해제 시 prefixed 토픽 사용하도록 수정 - application.properties에 기본 profile을 local로 설정 토픽 네이밍: - prod: clubId (기존 호환성 유지) - staging: staging_clubId - local: local_clubId --- .../club/service/ClubProfileService.java | 4 +- .../club/service/RecruitmentStateChecker.java | 4 +- .../club/util/RecruitmentStateCalculator.java | 14 +++++-- .../moadong/fcm/service/FcmAsyncService.java | 10 ++++- .../moadong/fcm/util/FcmTopicResolver.java | 18 +++++++++ .../service/ClubProfileServiceDateTest.java | 38 ++++++++----------- 6 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 5116ecba9..19ddbb18c 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -23,6 +23,7 @@ public class ClubProfileService { private final ClubRepository clubRepository; private final ClubSearchRepository clubSearchRepository; + private final RecruitmentStateCalculator recruitmentStateCalculator; @Transactional public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { @@ -37,7 +38,7 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, 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() @@ -57,3 +58,4 @@ public ClubDetailedResponse getClubDetail(String clubId) { return new ClubDetailedResponse(clubDetailedResult); } } + diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java index 151616fbb..9f53a0690 100644 --- a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -21,6 +21,7 @@ public class RecruitmentStateChecker { private final ClubRepository clubRepository; + private final RecruitmentStateCalculator recruitmentStateCalculator; @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 public void performTask() { @@ -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); } } } + diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index 8e4b89210..d8f295060 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -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); @@ -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(); @@ -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(); } } + diff --git a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java index 064fa51c7..404e6562a 100644 --- a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java +++ b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java @@ -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; @@ -30,6 +31,8 @@ public class FcmAsyncService { private final FirebaseMessaging firebaseMessaging; + private final FcmTopicResolver fcmTopicResolver; + @Value("${fcm.topic.timeout-seconds:5}") private int timeoutSeconds; @@ -40,14 +43,16 @@ public CompletableFuture updateSubscriptions(String token, Set 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)); } } @@ -84,3 +89,4 @@ public CompletableFuture updateSubscriptions(String token, Set new return CompletableFuture.completedFuture(null); } } + diff --git a/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java b/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java new file mode 100644 index 000000000..81781913e --- /dev/null +++ b/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java @@ -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; + } +} diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index 5650b77bd..d92858230 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -14,8 +14,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -26,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -41,6 +40,9 @@ public class ClubProfileServiceDateTest { @Mock ClubSearchRepository clubSearchRepository; + @Mock + RecruitmentStateCalculator recruitmentStateCalculator; + @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") @Test void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ @@ -49,27 +51,19 @@ public class ClubProfileServiceDateTest { CustomUserDetails customUserDetails = UserFixture.createUserDetails("test"); Club club = new Club(); when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(club)); - //updateClubRecruitmentInfo의 RecruitmentStateCalculator 무시 - try (var mocked = Mockito.mockStatic(RecruitmentStateCalculator.class)) { - mocked.when(() -> - RecruitmentStateCalculator.calculate( - Mockito.any(moadong.club.entity.Club.class), - Mockito.any(java.time.ZonedDateTime.class), - Mockito.any(java.time.ZonedDateTime.class) - ) - ).thenAnswer(inv -> null); + doNothing().when(recruitmentStateCalculator).calculate(any(), any(), any()); - //WHEN - clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); + //WHEN + clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); - //THEN - assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); - //1초 전후 차이로 살펴보기 - LocalDateTime now = LocalDateTime.now(); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isAfter(now.minusSeconds(1))); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isBefore(now.plusSeconds(1))); - } + //THEN + assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); + //1초 전후 차이로 살펴보기 + LocalDateTime now = LocalDateTime.now(); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isAfter(now.minusSeconds(1))); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isBefore(now.plusSeconds(1))); } } + From 88b053986b1f2ace2ccc1e2c7acc1305d3f7d54e Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 19:54:43 -0800 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 4e81a5333..1b7f46cbe 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -146,9 +146,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()); } } From 6b013cd17d1451ae3c7e3b1091aa5500ca3d9428 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 20:01:29 -0800 Subject: [PATCH 09/10] =?UTF-8?q?revert:=20FCM=20=ED=86=A0=ED=94=BD=20pref?= =?UTF-8?q?ix=20=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC=20(feature=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/Club.java | 6 +-- .../club/service/ClubProfileService.java | 4 +- .../club/service/RecruitmentStateChecker.java | 4 +- .../club/util/RecruitmentStateCalculator.java | 14 ++----- .../moadong/fcm/service/FcmAsyncService.java | 10 +---- .../moadong/fcm/util/FcmTopicResolver.java | 18 --------- .../service/ClubProfileServiceDateTest.java | 38 +++++++++++-------- 7 files changed, 31 insertions(+), 63 deletions(-) delete mode 100644 backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 1b7f46cbe..4e81a5333 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -146,11 +146,9 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus) public void sendPushNotification(Message message) { try { - log.info("FCM 알림 전송 시작 - clubId: {}, clubName: {}", this.id, this.name); - String messageId = FirebaseMessaging.getInstance().send(message); - log.info("FCM 알림 전송 성공 - clubId: {}, messageId: {}", this.id, messageId); + FirebaseMessaging.getInstance().send(message); } catch (FirebaseMessagingException e) { - log.error("FCM 알림 전송 실패 - clubId: {}, error: {}", this.id, e.getMessage()); + log.error("FirebaseSendNotificationError: {}", e.getMessage()); } } diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 19ddbb18c..5116ecba9 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -23,7 +23,6 @@ public class ClubProfileService { private final ClubRepository clubRepository; private final ClubSearchRepository clubSearchRepository; - private final RecruitmentStateCalculator recruitmentStateCalculator; @Transactional public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { @@ -38,7 +37,7 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, 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() @@ -58,4 +57,3 @@ public ClubDetailedResponse getClubDetail(String clubId) { return new ClubDetailedResponse(clubDetailedResult); } } - diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java index 9f53a0690..151616fbb 100644 --- a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -21,7 +21,6 @@ public class RecruitmentStateChecker { private final ClubRepository clubRepository; - private final RecruitmentStateCalculator recruitmentStateCalculator; @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 public void performTask() { @@ -33,10 +32,9 @@ public void performTask() { if (recruitInfo.getClubRecruitmentStatus() == ClubRecruitmentStatus.ALWAYS) { continue; } - recruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate); + RecruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate); clubRepository.save(club); } } } - diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index d8f295060..8e4b89210 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -8,21 +8,14 @@ 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; - private final FcmTopicResolver fcmTopicResolver; - - public void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) { + public static void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) { ClubRecruitmentStatus oldStatus = club.getClubRecruitmentInformation().getClubRecruitmentStatus(); ClubRecruitmentStatus newStatus = calculateRecruitmentStatus(recruitmentStartDate, recruitmentEndDate); club.updateRecruitmentStatus(newStatus); @@ -57,7 +50,7 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec return ClubRecruitmentStatus.CLOSED; } - public Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status) { + public static Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M월 d일 a h시 m분", Locale.KOREAN); ClubRecruitmentInformation info = club.getClubRecruitmentInformation(); @@ -79,8 +72,7 @@ public Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status) .setTitle(club.getName()) .setBody(bodyMessage) .build()) - .setTopic(fcmTopicResolver.resolveTopic(club.getId())) + .setTopic(club.getId()) .build(); } } - diff --git a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java index 404e6562a..064fa51c7 100644 --- a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java +++ b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java @@ -6,7 +6,6 @@ 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; @@ -31,8 +30,6 @@ public class FcmAsyncService { private final FirebaseMessaging firebaseMessaging; - private final FcmTopicResolver fcmTopicResolver; - @Value("${fcm.topic.timeout-seconds:5}") private int timeoutSeconds; @@ -43,16 +40,14 @@ public CompletableFuture updateSubscriptions(String token, Set new // 새로운 동아리 구독 if (!clubsToSubscribe.isEmpty()) { for (String clubId : clubsToSubscribe) { - String topic = fcmTopicResolver.resolveTopic(clubId); - futures.add(firebaseMessaging.subscribeToTopicAsync(Collections.singletonList(token), topic)); + futures.add(firebaseMessaging.subscribeToTopicAsync(Collections.singletonList(token), clubId)); } } // 더 이상 구독하지 않는 동아리 구독 해제 if (!clubsToUnsubscribe.isEmpty()) { for (String clubId : clubsToUnsubscribe) { - String topic = fcmTopicResolver.resolveTopic(clubId); - futures.add(firebaseMessaging.unsubscribeFromTopicAsync(Collections.singletonList(token), topic)); + futures.add(firebaseMessaging.unsubscribeFromTopicAsync(Collections.singletonList(token), clubId)); } } @@ -89,4 +84,3 @@ public CompletableFuture updateSubscriptions(String token, Set new return CompletableFuture.completedFuture(null); } } - diff --git a/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java b/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java deleted file mode 100644 index 81781913e..000000000 --- a/backend/src/main/java/moadong/fcm/util/FcmTopicResolver.java +++ /dev/null @@ -1,18 +0,0 @@ -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; - } -} diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index d92858230..5650b77bd 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -24,7 +26,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -40,9 +41,6 @@ public class ClubProfileServiceDateTest { @Mock ClubSearchRepository clubSearchRepository; - @Mock - RecruitmentStateCalculator recruitmentStateCalculator; - @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") @Test void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ @@ -51,19 +49,27 @@ public class ClubProfileServiceDateTest { CustomUserDetails customUserDetails = UserFixture.createUserDetails("test"); Club club = new Club(); when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(club)); - doNothing().when(recruitmentStateCalculator).calculate(any(), any(), any()); + //updateClubRecruitmentInfo의 RecruitmentStateCalculator 무시 + try (var mocked = Mockito.mockStatic(RecruitmentStateCalculator.class)) { + mocked.when(() -> + RecruitmentStateCalculator.calculate( + Mockito.any(moadong.club.entity.Club.class), + Mockito.any(java.time.ZonedDateTime.class), + Mockito.any(java.time.ZonedDateTime.class) + ) + ).thenAnswer(inv -> null); - //WHEN - clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); + //WHEN + clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); - //THEN - assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); - //1초 전후 차이로 살펴보기 - LocalDateTime now = LocalDateTime.now(); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isAfter(now.minusSeconds(1))); - assertTrue(club.getClubRecruitmentInformation(). - getLastModifiedDate().isBefore(now.plusSeconds(1))); + //THEN + assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); + //1초 전후 차이로 살펴보기 + LocalDateTime now = LocalDateTime.now(); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isAfter(now.minusSeconds(1))); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isBefore(now.plusSeconds(1))); + } } } - From 773badab428a5dbfd62f75c5f9bef3602ec5d45b Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Mon, 19 Jan 2026 20:31:51 -0800 Subject: [PATCH 10/10] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RecruitmentStateCheckerTest.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java index 6af038e24..9db389915 100644 --- a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java +++ b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java @@ -1,6 +1,7 @@ package moadong.club.service; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -13,6 +14,7 @@ import moadong.club.entity.ClubRecruitmentInformation; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.repository.ClubRepository; +import moadong.club.util.RecruitmentStateCalculator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -28,6 +30,9 @@ public class RecruitmentStateCheckerTest { @Mock private ClubRepository clubRepository; + @Mock + private RecruitmentStateCalculator recruitmentStateCalculator; + static final ZonedDateTime NOW = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); @Test @@ -42,19 +47,18 @@ public class RecruitmentStateCheckerTest { recruitmentStateChecker.performTask(); - verify(club, never()).updateRecruitmentStatus(any()); + verify(recruitmentStateCalculator, never()).calculate(any(), any(), any()); verify(clubRepository, never()).save(club); } @Test - void 모집시작전_14일이내면_UPCOMING() { + void 모집시작전_14일이내면_calculate호출() { Club club = mock(Club.class); ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); ZonedDateTime start = NOW.plusDays(10); ZonedDateTime end = NOW.plusDays(20); - when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); when(info.getRecruitmentStart()).thenReturn(start); @@ -63,19 +67,18 @@ public class RecruitmentStateCheckerTest { recruitmentStateChecker.performTask(); - verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); + verify(recruitmentStateCalculator).calculate(eq(club), eq(start), eq(end)); verify(clubRepository).save(club); } @Test - void 모집기간중이면_OPEN() { + void 모집기간중이면_calculate호출() { Club club = mock(Club.class); ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); ZonedDateTime start = NOW.minusDays(1); ZonedDateTime end = NOW.plusDays(5); - when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); when(info.getRecruitmentStart()).thenReturn(start); @@ -84,19 +87,18 @@ public class RecruitmentStateCheckerTest { recruitmentStateChecker.performTask(); - verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); + verify(recruitmentStateCalculator).calculate(eq(club), eq(start), eq(end)); verify(clubRepository).save(club); } @Test - void 모집마감_이후면_CLOSED() { + void 모집마감_이후면_calculate호출() { Club club = mock(Club.class); ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); ZonedDateTime start = NOW.minusDays(10); ZonedDateTime end = NOW.minusDays(1); - when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); when(info.getRecruitmentStart()).thenReturn(start); @@ -105,16 +107,15 @@ public class RecruitmentStateCheckerTest { recruitmentStateChecker.performTask(); - verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(recruitmentStateCalculator).calculate(eq(club), eq(start), eq(end)); verify(clubRepository).save(club); } @Test - void 시작_또는_종료날짜_null이면_CLOSED() { + void 시작_또는_종료날짜_null이면_calculate호출() { Club club = mock(Club.class); ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); - when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); when(info.getRecruitmentStart()).thenReturn(null); @@ -123,7 +124,8 @@ public class RecruitmentStateCheckerTest { recruitmentStateChecker.performTask(); - verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(recruitmentStateCalculator).calculate(eq(club), eq(null), eq(null)); verify(clubRepository).save(club); } } +