Skip to content

Commit 4cc92bc

Browse files
authored
Merge pull request #1189 from Moadong/develop/be
2 parents d6d0ee6 + 66acef9 commit 4cc92bc

23 files changed

+1375
-11
lines changed

backend/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,6 @@ summary*.html
4242

4343
application.properties
4444
moadong.json
45-
firebase.json
45+
firebase.json
46+
47+
/.cursor

backend/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ dependencies {
7373
implementation 'org.javers:javers-spring-boot-starter-mongo:7.10.0'
7474

7575
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
76+
77+
implementation 'net.javacrumbs.shedlock:shedlock-spring:7.6.0'
78+
implementation 'net.javacrumbs.shedlock:shedlock-provider-mongo:7.6.0'
7679
}
7780

7881
//전체 테스트
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package moadong.club.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import jakarta.validation.Valid;
7+
import lombok.AllArgsConstructor;
8+
import moadong.club.payload.request.ClubInfoRequest;
9+
import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
10+
import moadong.club.payload.response.ClubListResponse;
11+
import moadong.club.service.ClubProfileService;
12+
import moadong.global.payload.Response;
13+
import moadong.user.annotation.CurrentUser;
14+
import moadong.user.payload.CustomUserDetails;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.PutMapping;
19+
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RestController;
22+
23+
@RestController
24+
@RequestMapping("/api/admin")
25+
@AllArgsConstructor
26+
@Tag(name = "Club Admin", description = "동아리 관리자 API (개발자 전용)")
27+
public class ClubAdminController {
28+
29+
private final ClubProfileService clubProfileService;
30+
31+
@GetMapping("/clubs")
32+
@Operation(summary = "동아리 목록 조회", description = "전체 동아리 목록을 조회합니다. DEVELOPER 역할 필요.")
33+
@SecurityRequirement(name = "BearerAuth")
34+
public ResponseEntity<?> getAllClubs() {
35+
ClubListResponse response = clubProfileService.getAllClubsForAdmin();
36+
return Response.ok(response);
37+
}
38+
39+
@PutMapping("/club/{clubId}/info")
40+
@Operation(summary = "동아리 약력 수정 (관리자)", description = "지정한 clubId 동아리의 약력을 수정합니다. DEVELOPER 역할 필요.")
41+
@SecurityRequirement(name = "BearerAuth")
42+
public ResponseEntity<?> updateClubInfo(
43+
@CurrentUser CustomUserDetails user,
44+
@PathVariable String clubId,
45+
@RequestBody @Valid ClubInfoRequest request) {
46+
clubProfileService.updateClubInfoByClubId(clubId, request, user);
47+
return Response.ok("success update club info");
48+
}
49+
50+
@PutMapping("/club/{clubId}/description")
51+
@Operation(summary = "동아리 모집정보 수정 (관리자)", description = "지정한 clubId 동아리의 모집정보를 수정합니다. DEVELOPER 역할 필요.")
52+
@SecurityRequirement(name = "BearerAuth")
53+
public ResponseEntity<?> updateClubDescription(
54+
@CurrentUser CustomUserDetails user,
55+
@PathVariable String clubId,
56+
@RequestBody ClubRecruitmentInfoUpdateRequest request) {
57+
clubProfileService.updateClubRecruitmentInfoByClubId(clubId, request, user);
58+
return Response.ok("success update club description");
59+
}
60+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package moadong.club.payload.response;
2+
3+
import java.util.List;
4+
5+
public record ClubListResponse(List<ClubListResponseItem> clubs) {
6+
7+
public record ClubListResponseItem(String id, String name, String userId) {
8+
}
9+
}

backend/src/main/java/moadong/club/repository/ClubRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.List;
44
import java.util.Optional;
55
import moadong.club.entity.Club;
6+
import moadong.club.enums.ClubRecruitmentStatus;
67
import org.bson.types.ObjectId;
78
import org.javers.spring.annotation.JaversSpringDataAuditable;
89
import org.springframework.data.mongodb.repository.MongoRepository;
@@ -17,4 +18,6 @@ public interface ClubRepository extends MongoRepository<Club, String> {
1718
List<Club> findAllByName(List<String> clubs);
1819

1920
Long countByIdIn(List<String> id);
21+
22+
List<Club> findAllByClubRecruitmentInformation_ClubRecruitmentStatus(ClubRecruitmentStatus status);
2023
}

backend/src/main/java/moadong/club/service/ClubProfileService.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import moadong.club.payload.request.ClubInfoRequest;
99
import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
1010
import moadong.club.payload.response.ClubDetailedResponse;
11+
import moadong.club.payload.response.ClubListResponse;
1112
import moadong.club.repository.ClubRepository;
1213
import moadong.club.repository.ClubSearchRepository;
1314
import moadong.club.util.RecruitmentStateCalculator;
@@ -57,6 +58,18 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request,
5758
javers.commit(user.getUsername(), saved);
5859
}
5960

61+
public ClubListResponse getAllClubsForAdmin() {
62+
List<Club> all = clubRepository.findAll();
63+
List<ClubListResponse.ClubListResponseItem> items = all.stream()
64+
.map(c -> new ClubListResponse.ClubListResponseItem(
65+
c.getId() != null ? c.getId() : "",
66+
c.getName() != null ? c.getName() : "",
67+
c.getUserId() != null ? c.getUserId() : ""
68+
))
69+
.toList();
70+
return new ClubListResponse(items);
71+
}
72+
6073
public ClubDetailedResponse getClubDetail(String clubId) {
6174
ObjectId objectId = ObjectIdConverter.convertString(clubId);
6275
Club club = clubRepository.findClubById(objectId)
@@ -67,5 +80,32 @@ public ClubDetailedResponse getClubDetail(String clubId) {
6780
);
6881
return new ClubDetailedResponse(clubDetailedResult);
6982
}
83+
84+
@Transactional
85+
public void updateClubInfoByClubId(String clubId, ClubInfoRequest request, CustomUserDetails user) {
86+
ObjectId objectId = ObjectIdConverter.convertString(clubId);
87+
Club club = clubRepository.findClubById(objectId)
88+
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
89+
club.update(request);
90+
Club saved = clubRepository.save(club);
91+
javers.commit(user.getUsername(), saved);
92+
}
93+
94+
@Transactional
95+
public void updateClubRecruitmentInfoByClubId(String clubId, ClubRecruitmentInfoUpdateRequest request,
96+
CustomUserDetails user) {
97+
ObjectId objectId = ObjectIdConverter.convertString(clubId);
98+
Club club = clubRepository.findClubById(objectId)
99+
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
100+
club.update(request);
101+
recruitmentStateCalculator.calculate(
102+
club,
103+
club.getClubRecruitmentInformation().getRecruitmentStart(),
104+
club.getClubRecruitmentInformation().getRecruitmentEnd()
105+
);
106+
club.getClubRecruitmentInformation().updateLastModifiedDate();
107+
Club saved = clubRepository.save(club);
108+
javers.commit(user.getUsername(), saved);
109+
}
70110
}
71111

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package moadong.club.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import moadong.club.entity.Club;
6+
import moadong.club.entity.ClubRecruitmentInformation;
7+
import moadong.club.enums.ClubRecruitmentStatus;
8+
import moadong.club.repository.ClubRepository;
9+
import moadong.club.util.RecruitmentDdayNotificationBuilder;
10+
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
11+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
12+
import org.springframework.scheduling.annotation.Scheduled;
13+
import org.springframework.stereotype.Component;
14+
15+
import java.time.LocalDate;
16+
import java.time.ZoneId;
17+
import java.time.ZonedDateTime;
18+
import java.time.temporal.ChronoUnit;
19+
import java.util.List;
20+
import java.util.Set;
21+
22+
@Slf4j
23+
@Component
24+
@RequiredArgsConstructor
25+
@ConditionalOnProperty(name = "scheduling.enabled", havingValue = "true", matchIfMissing = true)
26+
public class RecruitmentDdayNotifier {
27+
28+
private static final Set<Long> NOTIFICATION_DAYS = Set.of(7L, 3L, 1L);
29+
private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul");
30+
31+
private final ClubRepository clubRepository;
32+
private final RecruitmentDdayNotificationBuilder notificationBuilder;
33+
34+
@Scheduled(cron = "0 0 8 * * *", zone = "Asia/Seoul")
35+
@SchedulerLock(name = "RecruitmentDdayNotifier", lockAtMostFor = "5m", lockAtLeastFor = "1m")
36+
public void sendDdayNotifications() {
37+
log.info("D-Day 알림 스케줄러 시작");
38+
39+
List<Club> openClubs = clubRepository
40+
.findAllByClubRecruitmentInformation_ClubRecruitmentStatus(ClubRecruitmentStatus.OPEN);
41+
42+
LocalDate today = LocalDate.now(SEOUL_ZONE);
43+
int sentCount = 0;
44+
45+
for (Club club : openClubs) {
46+
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
47+
ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();
48+
49+
if (recruitmentEnd == null) {
50+
continue;
51+
}
52+
53+
long daysLeft = ChronoUnit.DAYS.between(today, recruitmentEnd.toLocalDate());
54+
55+
if (NOTIFICATION_DAYS.contains(daysLeft)) {
56+
log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
57+
club.sendPushNotification(notificationBuilder.build(club, daysLeft));
58+
sentCount++;
59+
}
60+
}
61+
62+
log.info("D-Day 알림 스케줄러 완료 - 전송: {}건 / 대상: {}건", sentCount, openClubs.size());
63+
}
64+
}

backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import moadong.club.enums.ClubRecruitmentStatus;
88
import moadong.club.repository.ClubRepository;
99
import moadong.club.util.RecruitmentStateCalculator;
10+
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
1011
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1112
import org.springframework.scheduling.annotation.Scheduled;
1213
import org.springframework.stereotype.Component;
@@ -23,7 +24,8 @@ public class RecruitmentStateChecker {
2324
private final ClubRepository clubRepository;
2425
private final RecruitmentStateCalculator recruitmentStateCalculator;
2526

26-
@Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행
27+
@Scheduled(fixedRate = 10 * 60 * 1000) // 10분마다 실행
28+
@SchedulerLock(name="RecruitmentStateChecker", lockAtMostFor = "1m", lockAtLeastFor = "1s")
2729
public void performTask() {
2830
List<Club> clubs = clubRepository.findAll();
2931
for (Club club : clubs) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package moadong.club.util;
2+
3+
import com.google.firebase.messaging.Message;
4+
import com.google.firebase.messaging.Notification;
5+
import lombok.RequiredArgsConstructor;
6+
import moadong.club.entity.Club;
7+
import moadong.fcm.enums.FcmAction;
8+
import moadong.fcm.util.FcmTopicResolver;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.Map;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class RecruitmentDdayNotificationBuilder {
16+
17+
private final FcmTopicResolver fcmTopicResolver;
18+
19+
public Message build(Club club, long daysLeft) {
20+
String body = resolveBody(daysLeft);
21+
22+
return Message.builder()
23+
.setNotification(Notification.builder()
24+
.setTitle(club.getName())
25+
.setBody(body)
26+
.build())
27+
.putAllData(buildData(club))
28+
.setTopic(fcmTopicResolver.resolveTopic(club.getId()))
29+
.build();
30+
}
31+
32+
private String resolveBody(long daysLeft) {
33+
return switch ((int) daysLeft) {
34+
case 7 -> "모집 마감까지 7일 남았어요! 관심 있다면 서둘러 지원하세요 🔥";
35+
case 3 -> "모집 마감 3일 전이에요! 놓치지 말고 지금 바로 지원하세요 ⏰";
36+
case 1 -> "내일 모집이 마감돼요! 마지막 기회를 놓치지 마세요 🚨";
37+
default -> throw new IllegalArgumentException("Unsupported daysLeft: " + daysLeft);
38+
};
39+
}
40+
41+
private Map<String, String> buildData(Club club) {
42+
return Map.of(
43+
"path", "/webview/clubDetail/" + club.getId(),
44+
"action", FcmAction.NAVIGATE_WEBVIEW.name(),
45+
"clubId", club.getId()
46+
);
47+
}
48+
}

backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import java.time.format.DateTimeFormatter;
66
import java.time.temporal.ChronoUnit;
77
import java.util.Locale;
8+
import java.util.Map;
89

910
import com.google.firebase.messaging.Message;
1011
import com.google.firebase.messaging.Notification;
1112
import lombok.RequiredArgsConstructor;
1213
import moadong.club.entity.Club;
1314
import moadong.club.entity.ClubRecruitmentInformation;
1415
import moadong.club.enums.ClubRecruitmentStatus;
16+
import moadong.fcm.enums.FcmAction;
1517
import moadong.fcm.util.FcmTopicResolver;
1618
import org.springframework.stereotype.Component;
1719

@@ -79,8 +81,16 @@ public Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status)
7981
.setTitle(club.getName())
8082
.setBody(bodyMessage)
8183
.build())
84+
.putAllData(buildNotificationData(club))
8285
.setTopic(fcmTopicResolver.resolveTopic(club.getId()))
8386
.build();
8487
}
85-
}
8688

89+
public Map<String, String> buildNotificationData(Club club) {
90+
return Map.of(
91+
"path", "/webview/clubDetail/" + club.getId(),
92+
"action", FcmAction.NAVIGATE_WEBVIEW.name(),
93+
"clubId", club.getId()
94+
);
95+
}
96+
}

0 commit comments

Comments
 (0)