Skip to content

Conversation

@1winhyun
Copy link
Member

@1winhyun 1winhyun commented Jan 14, 2026

🔀 변경 내용

  • 관리자가 유저에게 보상 지급 시 유저에게 알림이 가도록 구현했습니다.

✅ 작업 항목

  • 관리자가 보상 지급 시 유저에게 알림 전송 기능 구현
  • 알림 전송 시 해당 알림 DB에 저장되도록 구현

📸 스크린샷 (선택)

보상 지급 시 알림 전송

image image

알림함에 보상 지급 알림 저장

image

📎 참고 이슈

관련 이슈 번호 #72

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능
    • 보상 수여 시 사용자의 기기로 푸시 알림이 즉시 전송되어 보상을 실시간으로 확인 가능
    • 모든 보상 알림이 알림 센터에 자동으로 저장되어 과거 이력을 언제든 조회 가능
    • 실시간 이벤트 기반 알림 처리로 더욱 신속하고 안정적인 서비스 제공

✏️ Tip: You can customize this high-level summary in your review settings.

@1winhyun 1winhyun self-assigned this Jan 14, 2026
@1winhyun 1winhyun added ♻️Refactor 리팩토링 ✨Feat 새로운 기능 개발 labels Jan 14, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 14, 2026

Walkthrough

보상 부여 시 이벤트 기반 아키텍처를 도입합니다. 사용자에게 보상을 부여할 때 RewardGrantedEvent를 발행하고, RewardPushListener가 이를 구독하여 알림 저장 및 Firebase 푸시 메시지 전송을 수행합니다.

Changes

Cohort / File(s) 요약
이벤트 및 DTO 정의
src/main/java/com/campus/campus/domain/manager/application/dto/request/RewardGrantedEvent.java, src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java
RewardGrantedEvent 레코드 추가 (userId, rewardName 필드), NotificationType enum에 REWARD_GRANTED 상수 추가
이벤트 발행 로직
src/main/java/com/campus/campus/domain/manager/application/mapper/ManagerMapper.java, src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java
ManagerMapper에 createRewardGrantedEvent() 팩토리 메서드 추가, ManagerService에 ApplicationEventPublisher 주입 및 grantRewardToUser에서 이벤트 발행
이벤트 리스닝 및 알림 처리
src/main/java/com/campus/campus/domain/manager/application/service/RewardPushListener.java, src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java
RewardPushListener 컴포넌트 추가 (@EventListener, @Async 적용), RewardGrantedEvent 수신 후 NotificationService와 FirebaseCloudMessageService를 통해 알림 및 푸시 처리, NotificationService에 saveRewardGrantedNotification() 메서드 추가

Sequence Diagram

sequenceDiagram
    participant ManagerService
    participant ApplicationEventPublisher
    participant RewardPushListener
    participant NotificationService
    participant FirebaseCloudMessageService

    ManagerService->>ManagerService: grantRewardToUser()
    ManagerService->>ManagerService: 보상 저장 및 사용자 상태 업데이트
    ManagerService->>ApplicationEventPublisher: publishEvent(RewardGrantedEvent)
    
    ApplicationEventPublisher->>RewardPushListener: handleRewardGrantedEvent() [비동기]
    
    RewardPushListener->>NotificationService: saveRewardGrantedNotification()
    NotificationService->>NotificationService: 사용자 조회 및 검증
    NotificationService->>NotificationService: REWARD_GRANTED 타입 알림 생성 및 저장
    
    RewardPushListener->>RewardPushListener: 토픽명 생성 및 로깅
    RewardPushListener->>FirebaseCloudMessageService: publishMessage()
    FirebaseCloudMessageService->>FirebaseCloudMessageService: REWARD_GRANTED 페이로드 포함 FCM 전송
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 보상의 신호가 날아가고,
비동기의 바람 위에 승승장구,
알림과 푸시가 춤을 추네,
이벤트의 조화로운 교향곡! 🎵✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 관리자가 유저에게 보상 지급 시 유저에게 알림이 가도록 구현하는 주요 변경사항을 명확하게 요약하고 있으며, 변경 사항과 완벽하게 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

🧹 Recent nitpick comments
src/main/java/com/campus/campus/domain/manager/application/mapper/ManagerMapper.java (1)

76-81: 이벤트 생성을 Mapper에 두는 것에 대한 검토가 필요합니다.

기능적으로는 정상 동작하지만, RewardGrantedEvent는 도메인 이벤트로서 Mapper보다는 Service 레이어나 별도의 이벤트 팩토리에서 생성하는 것이 책임 분리 측면에서 더 적절할 수 있습니다. Mapper는 일반적으로 엔티티-DTO 변환에 집중하는 것이 관례입니다.

다만 현재 구현이 동작에는 문제가 없으므로, 향후 리팩토링 시 고려해 주세요.


📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6acde7b and 775dbaf.

📒 Files selected for processing (3)
  • src/main/java/com/campus/campus/domain/manager/application/mapper/ManagerMapper.java
  • src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java
  • src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (1)
src/main/java/com/campus/campus/domain/user/application/exception/UserNotFoundException.java (1)
  • UserNotFoundException (5-9)
🔇 Additional comments (1)
src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (1)

104-113: LGTM!

새로운 saveRewardGrantedNotification 메서드가 기존 savePostCreatedNotification 메서드와 일관된 패턴으로 구현되었습니다. REQUIRES_NEW 전파 설정은 이벤트 리스너에서 호출되는 컨텍스트에 적합하며, 소프트 딜리트 사용자 체크도 잘 처리되어 있습니다.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In
`@src/main/java/com/campus/campus/domain/notification/application/dto/NotificationListResponse.java`:
- Around line 5-9: Remove the unused DTO class NotificationListResponse: delete
the NotificationListResponse record and any import or reference to it, since
CursorResponse<NotificationResponse> is already used by NotificationController
and NotificationService; ensure there are no lingering imports, tests, or
compilation references to NotificationListResponse after deletion and run a
build to confirm clean compile.

In
`@src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java`:
- Around line 14-16: Update the numeric codes for the notification domain enum
entries in ErrorCode so they no longer collide with the review domain: change
NOTIFICATION_NOT_FOUND from 2801 to 2901, NOTIFICATION_ACCESS_DENIED from 2802
to 2902, and INVALID_NOTIFICATION_IDS from 2803 to 2903 in the ErrorCode enum
definition (the constants NOTIFICATION_NOT_FOUND, NOTIFICATION_ACCESS_DENIED,
INVALID_NOTIFICATION_IDS).
🧹 Nitpick comments (11)
src/main/java/com/campus/campus/domain/manager/application/dto/request/RewardGrantedEvent.java (1)

1-10: LGTM!

이벤트 record 클래스가 간결하게 잘 구현되었습니다. @Builder 패턴을 사용하여 가독성 좋은 객체 생성을 지원합니다.

참고 (선택사항): 이 클래스는 Spring Application Event로 사용되므로, dto/request 패키지보다는 event 패키지에 위치시키는 것이 의미상 더 명확할 수 있습니다.

src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java (1)

16-19: 미래 시간 입력에 대한 방어 로직 고려

dateTime이 미래 시간인 경우 duration.getSeconds()가 음수가 되어 예상치 못한 결과가 반환될 수 있습니다. 현재 사용 용도상 문제가 되지 않을 수 있지만, 방어적 처리를 추가하면 더 안전합니다.

♻️ 미래 시간 처리 제안
 	LocalDateTime now = LocalDateTime.now();
 	Duration duration = Duration.between(dateTime, now);

 	long seconds = duration.getSeconds();
+	if (seconds < 0) {
+		return "방금 전"; // 또는 적절한 기본값
+	}
src/main/java/com/campus/campus/domain/notification/domain/entity/Notification.java (1)

23-69: LGTM!

JPA 엔티티가 잘 설계되었습니다. FetchType.LAZY, EnumType.STRING 사용, protected no-args 생성자 등 JPA 모범 사례를 잘 따르고 있습니다.

운영 관점 권고 (선택사항): 알림 목록 조회 시 user_id 컬럼에 대한 인덱스가 필요할 수 있습니다. 데이터 증가에 따라 조회 성능 최적화를 위해 DB 인덱스 추가를 고려해 주세요.

`@Table`(indexes = `@Index`(name = "idx_notification_user_id", columnList = "user_id"))
src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java (1)

5-9: LGTM! 일관성 관련 참고 사항

제네릭 record를 활용한 깔끔한 페이지네이션 응답 구현입니다. NextCursor로 커서를 캡슐화하여 API 응답 구조가 명확합니다.

참고로, CursorPageReviewResponse(review 도메인)는 nextCursorCreatedAt, nextCursorId를 별도 필드로 사용하는 반면, 이 구현은 NextCursor 객체로 래핑합니다. 향후 전체 코드베이스에서 커서 응답 패턴을 통일하는 것을 고려해 볼 수 있습니다.

src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java (1)

134-134: 하드코딩된 문자열 상수 추출 고려

"스탬프 보상" 문자열이 하드코딩되어 있습니다. 향후 유지보수성을 위해 상수로 추출하는 것을 고려해 볼 수 있습니다.

♻️ 제안된 리팩토링
+	private static final String STAMP_REWARD_NAME = "스탬프 보상";
+
 	`@Transactional`
 	public void grantRewardToUser(Long userId, RewardRequest rewardRequest) {
 		// ...
-		eventPublisher.publishEvent(managerMapper.createRewardGrantedEvent(userId, "스탬프 보상"));
+		eventPublisher.publishEvent(managerMapper.createRewardGrantedEvent(userId, STAMP_REWARD_NAME));
 	}
src/main/java/com/campus/campus/domain/manager/application/service/RewardPushListener.java (1)

27-47: DB 저장과 FCM 전송 간 에러 처리 고려 필요

현재 saveRewardGrantedNotification 실패 시에도 FCM 전송이 시도되고, 반대로 FCM 전송 실패 시에도 알림은 이미 DB에 저장된 상태가 됩니다. 부분 실패 시나리오에 대한 처리가 필요한지 검토해 주세요.

♻️ 에러 처리 추가 제안
 `@Async`
 `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
 public void handleRewardGrantedEvent(RewardGrantedEvent rewardGrantedEvent) {
 	String title = "보상 지급 알림";
 	String body = "스탬프 보상이 지급되었습니다. 보상함을 확인해주세요.";

-	notificationService.saveRewardGrantedNotification(rewardGrantedEvent.userId(), title, body);
+	try {
+		notificationService.saveRewardGrantedNotification(rewardGrantedEvent.userId(), title, body);
+	} catch (Exception e) {
+		log.error("[PUSH] Failed to save reward notification. userId={}", rewardGrantedEvent.userId(), e);
+		return;
+	}

 	String userTopic = "user_" + rewardGrantedEvent.userId();

 	log.info("[PUSH] Reward granted. topic={}, userId={}", userTopic, rewardGrantedEvent.userId());

-	firebaseCloudMessageService.sendToTopic(
-		userTopic,
-		title,
-		body,
-		Map.of(
-			DATA_KEY_TYPE, DATA_TYPE_REWARD_GRANTED
-		)
-	);
+	try {
+		firebaseCloudMessageService.sendToTopic(
+			userTopic,
+			title,
+			body,
+			Map.of(
+				DATA_KEY_TYPE, DATA_TYPE_REWARD_GRANTED
+			)
+		);
+	} catch (Exception e) {
+		log.error("[PUSH] Failed to send FCM. topic={}, userId={}", userTopic, rewardGrantedEvent.userId(), e);
+	}
 }
src/main/java/com/campus/campus/domain/notification/presentation/NotificationResponseCode.java (2)

10-12: 다른 ResponseCode enum과의 일관성을 위해 @AllArgsConstructor 사용 권장

EmailVerificationResponseCode, StampResponseCode 등 다른 ResponseCode enum들은 @AllArgsConstructor를 사용하고 있습니다. 코드베이스 전반의 일관성을 위해 동일한 어노테이션 사용을 권장합니다.

♻️ 어노테이션 변경 제안
 `@Getter`
-@RequiredArgsConstructor
+@AllArgsConstructor
 public enum NotificationResponseCode implements ResponseCodeInterface {

14-19: 메시지 포맷 일관성 검토

일부 메시지는 "~성공"으로 끝나고, NOTIFICATION_UNREAD_EXISTS_SUCCESS는 "~성공했습니다."로 끝납니다. 일관된 메시지 포맷을 사용하면 좋겠습니다.

src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java (1)

24-29: 미사용 @Slf4j 어노테이션

@Slf4j 어노테이션이 선언되어 있지만 컨트롤러 내에서 로깅을 사용하지 않습니다. 제거하거나, 필요시 로깅을 추가해 주세요.

♻️ 미사용 어노테이션 제거
-@Slf4j
 `@RestController`
 `@RequestMapping`("/notifications")
 `@RequiredArgsConstructor`
 `@Tag`(name = "알림", description = "알림 관련 API")
 public class NotificationController {
src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (2)

38-69: 읽기 작업에 @Transactional(readOnly = true) 누락

getNotificationsByCursor 메서드는 데이터베이스에서 데이터를 조회하는 읽기 작업이지만 @Transactional 어노테이션이 없습니다. 다른 읽기 메서드인 hasUnread에는 @Transactional(readOnly = true)가 적용되어 있으므로 일관성을 위해 추가하는 것이 좋습니다.

♻️ 제안하는 수정 사항
+	`@Transactional`(readOnly = true)
 	public CursorResponse<NotificationResponse> getNotificationsByCursor(
 		Long userId,
 		LocalDateTime cursorCreatedAt,
 		Long cursorId,
 		int limit
 	) {

120-132: 토픽 파싱 시 예외 처리 부재

topic.split("_")의 결과가 예상과 다를 경우(예: _가 없거나, ID가 숫자가 아닐 경우) ArrayIndexOutOfBoundsException 또는 NumberFormatException이 발생할 수 있습니다. 비동기 이벤트 처리에서 이러한 예외는 디버깅을 어렵게 만들 수 있습니다.

♻️ 방어적 코드 추가 제안
 private List<User> findUsersByTopic(String topic) {
+	if (topic == null || !topic.contains("_")) {
+		log.warn("Invalid topic format: {}", topic);
+		return List.of();
+	}
 
 	String[] parts = topic.split("_");
+	if (parts.length < 2) {
+		log.warn("Invalid topic format: {}", topic);
+		return List.of();
+	}
+	
 	String scope = parts[0];
-	Long scopeId = Long.valueOf(parts[1]);
+	Long scopeId;
+	try {
+		scopeId = Long.valueOf(parts[1]);
+	} catch (NumberFormatException e) {
+		log.warn("Invalid scopeId in topic: {}", topic);
+		return List.of();
+	}
 
 	return switch (scope) {
 		case "major" -> userRepository.findAllByMajor_MajorIdAndDeletedAtIsNull(scopeId);
 		case "college" -> userRepository.findAllByCollege_CollegeIdAndDeletedAtIsNull(scopeId);
 		case "school" -> userRepository.findAllBySchool_SchoolIdAndDeletedAtIsNull(scopeId);
 		default -> List.of();
 	};
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8116d79 and 6acde7b.

📒 Files selected for processing (22)
  • src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
  • src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java
  • src/main/java/com/campus/campus/domain/manager/application/dto/request/RewardGrantedEvent.java
  • src/main/java/com/campus/campus/domain/manager/application/mapper/ManagerMapper.java
  • src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java
  • src/main/java/com/campus/campus/domain/manager/application/service/RewardPushListener.java
  • src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java
  • src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java
  • src/main/java/com/campus/campus/domain/notification/application/dto/NotificationListResponse.java
  • src/main/java/com/campus/campus/domain/notification/application/dto/NotificationResponse.java
  • src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java
  • src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java
  • src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java
  • src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java
  • src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java
  • src/main/java/com/campus/campus/domain/notification/domain/entity/Notification.java
  • src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java
  • src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java
  • src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java
  • src/main/java/com/campus/campus/domain/notification/presentation/NotificationResponseCode.java
  • src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java
  • src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java
🧰 Additional context used
🧬 Code graph analysis (10)
src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java (2)
src/main/java/com/campus/campus/domain/manager/presentation/ManagerController.java (2)
  • RestController (29-94)
  • PostMapping (83-93)
src/main/java/com/campus/campus/domain/manager/domain/entity/Manager.java (1)
  • Entity (15-35)
src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java (2)
src/main/java/com/campus/campus/domain/review/application/dto/response/CursorPageReviewResponse.java (1)
  • Getter (8-16)
src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java (1)
  • toCursorReviewResponse (80-88)
src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java (1)
src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java (1)
  • Component (8-53)
src/main/java/com/campus/campus/domain/manager/application/service/RewardPushListener.java (5)
src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (1)
  • Slf4j (29-133)
src/main/java/com/campus/campus/domain/stamp/domain/entity/Reward.java (1)
  • Entity (21-39)
src/main/java/com/campus/campus/domain/stamp/domain/repository/RewardRepository.java (1)
  • RewardRepository (10-12)
src/main/java/com/campus/campus/domain/stamp/application/dto/response/RewardResponse.java (1)
  • RewardResponse (7-17)
src/main/java/com/campus/campus/domain/manager/presentation/ManagerController.java (1)
  • PostMapping (83-93)
src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java (6)
src/main/java/com/campus/campus/domain/mail/application/exception/ErrorCode.java (1)
  • Getter (10-23)
src/main/java/com/campus/campus/domain/council/application/exception/ErrorCode.java (1)
  • Getter (10-28)
src/main/java/com/campus/campus/global/common/exception/ErrorCodeInterface.java (1)
  • ErrorCodeInterface (5-11)
src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java (1)
  • Getter (10-38)
src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java (1)
  • Getter (10-20)
src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java (1)
  • Getter (10-21)
src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java (2)
src/main/java/com/campus/campus/domain/review/application/dto/response/CursorPageReviewResponse.java (1)
  • Getter (8-16)
src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java (2)
  • toCursorReviewResponse (80-88)
  • toEmptyCursorReviewResponse (38-45)
src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (3)
src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java (1)
  • NotificationAccessDeniedException (5-9)
src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java (1)
  • NotificationNotFoundException (5-9)
src/main/java/com/campus/campus/domain/user/application/exception/UserNotFoundException.java (1)
  • UserNotFoundException (5-9)
src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java (1)
src/main/java/com/campus/campus/domain/councilpost/application/exception/PostAccessDeniedException.java (2)
  • PostAccessDeniedException (5-9)
  • PostAccessDeniedException (6-8)
src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java (1)
src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java (1)
  • ReviewRepository (15-44)
src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java (1)
src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java (1)
  • Component (13-41)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (21)
src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java (1)

1-49: 포매팅 변경 사항 승인

이 파일의 변경 사항은 메서드 선언부의 포매팅만 조정한 것으로, 로직에는 영향이 없습니다. @Override 어노테이션과 메서드 시그니처를 별도의 줄로 분리하고 공백 줄을 추가한 것이 일관되게 적용되어 있습니다.

다만, 이 파일(CouncilType.java)은 학생회 타입별 접근 제어 및 토픽 생성을 담당하는 enum으로, PR의 주요 목적인 "관리자가 유저에게 보상 지급 시 알림 전송" 기능과는 직접적인 연관이 없어 보입니다. 코드베이스 작업 중 부수적으로 정리된 것으로 판단됩니다.

src/main/java/com/campus/campus/domain/manager/application/mapper/ManagerMapper.java (1)

74-79: LGTM!

기존 mapper 패턴과 일관성 있게 createRewardGrantedEvent 메서드가 잘 구현되었습니다.

src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java (1)

1-7: LGTM!

알림 타입 enum이 간결하게 정의되었습니다. REWARD_GRANTED 타입이 이번 PR의 보상 지급 알림 기능을 위해 적절히 추가되었습니다.

src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java (1)

45-51: LGTM!

새로 추가된 쿼리 메서드들이 Spring Data JPA 네이밍 컨벤션을 잘 따르고 있으며, 기존 soft delete 패턴(DeletedAtIsNull)과 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java (1)

10-21: 구현 패턴 일관성 확인

ErrorCodeInterface 구현 및 Lombok 어노테이션 사용이 다른 도메인의 ErrorCode 패턴과 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java (1)

5-9: LGTM!

기존 PostAccessDeniedException 패턴과 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java (1)

5-9: LGTM!

표준 예외 패턴을 따르며, NotificationAccessDeniedException과 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java (3)

16-16: LGTM!

첫 페이지 조회용 메서드로, Spring Data JPA 쿼리 메서드 네이밍 컨벤션을 올바르게 따르고 있습니다.


34-34: LGTM!

읽지 않은 알림 존재 여부 확인 메서드가 Spring Data JPA 네이밍 컨벤션을 따르고 있습니다.


18-32: 커서 기반 페이지네이션 설계 확인됨 - 단, 디자인 패턴 일관성 검토 권장

서비스 레이어에서 isFirst 플래그를 통해 첫 페이지는 findByUserOrderByCreatedAtDescIdDesc를, 이후 페이지는 findNextByCursor를 올바르게 분리하여 사용하고 있습니다 (NotificationService 52-54줄).

구현 자체는 의도된 설계이며 정상적으로 동작합니다. 다만 ReviewRepositorycursorCreatedAt IS NULL 조건으로 단일 메서드에서 첫/이후 페이지를 모두 처리하는 반면, 여기서는 메서드를 분리한 점이 차이입니다. 두 접근 방식 모두 기능적으로 유효하나, 동일 패턴의 적용으로 일관성을 높이는 것을 검토하세요.

src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java (1)

5-8: LGTM!

커서 기반 페이지네이션을 위한 간결한 record 구현입니다. LocalDateTime을 사용하여 타입 안전성을 확보했고, Long을 사용하여 마지막 페이지에서 null 커서를 표현할 수 있습니다.

src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java (1)

41-52: 알림 저장과 Firebase 전송 간 에러 처리 검토 필요

saveCouncilPostCreated 호출 후 firebaseCloudMessageService.sendToTopic 호출 시, 두 작업 중 하나가 실패하면 불일치가 발생할 수 있습니다:

  • 알림 저장 성공 → Firebase 전송 실패: DB에 알림은 저장되었으나 실제 푸시는 미전송
  • 알림 저장 실패 시 예외가 발생하면 Firebase 전송도 스킵됨 (이 경우는 괜찮음)

현재 구조상 Firebase 전송 실패 시에도 알림이 DB에 남아 있어 사용자가 알림함에서 확인할 수 있으므로 허용 가능한 동작일 수 있습니다. 의도된 동작인지 확인해 주세요.

src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java (1)

123-135: LGTM! 이벤트 발행 패턴이 올바르게 구현되었습니다.

@Transactional 메서드 내에서 이벤트를 발행하고, 리스너에서 @TransactionalEventListener(AFTER_COMMIT)를 사용하면 트랜잭션 커밋 후에만 알림이 전송됩니다. 트랜잭션이 롤백되면 이벤트도 처리되지 않아 데이터 일관성이 보장됩니다.

src/main/java/com/campus/campus/domain/notification/application/dto/NotificationResponse.java (1)

7-28: LGTM!

DTO record 구조가 적절하고, Swagger 문서화 어노테이션이 잘 작성되어 있습니다. createdAt을 상대 시간 문자열로 처리하는 설계가 클라이언트 친화적입니다.

src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java (1)

13-41: LGTM!

Mapper 구현이 깔끔합니다. toResponsecreateNotification 메서드가 단일 책임 원칙을 잘 따르고 있으며, TimeFormatter를 통한 상대 시간 포맷팅 처리가 적절합니다.

src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java (2)

33-50: LGTM!

커서 기반 페이지네이션 구현이 적절합니다. @DateTimeFormat을 통한 ISO 날짜 파싱과 optional 파라미터 처리가 잘 되어 있습니다.


52-78: LGTM!

markAsReadhasUnread 엔드포인트가 명확하게 구현되어 있습니다. PATCH 메서드 사용이 적절하고, 응답 코드 처리가 일관성 있습니다.

src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java (4)

71-84: LGTM!

사용자 검증, 알림 존재 확인, 소유권 확인이 모두 적절하게 구현되어 있습니다. @Transactional 어노테이션도 올바르게 적용되어 있습니다.


86-102: LGTM!

REQUIRES_NEW 전파를 사용하여 이벤트 리스너에서 독립적인 트랜잭션으로 알림을 저장하는 것이 적절합니다. 배치 저장을 통해 여러 사용자에게 효율적으로 알림을 생성합니다.


104-113: LGTM!

PR 목표에 맞게 보상 지급 알림 저장 기능이 구현되었습니다. findByIdAndDeletedAtIsNull을 사용하여 논리적으로 삭제된 사용자를 제외하고, REQUIRES_NEW 트랜잭션 전파를 통해 이벤트 리스너에서 독립적으로 처리됩니다.


115-118: LGTM!

읽지 않은 알림 존재 여부를 효율적으로 확인하는 간단한 구현입니다.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 14 to 16
NOTIFICATION_NOT_FOUND(2801, HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."),
NOTIFICATION_ACCESS_DENIED(2802, HttpStatus.FORBIDDEN, "해당 알림에 접근할 권한이 없습니다."),
INVALID_NOTIFICATION_IDS(2803, HttpStatus.BAD_REQUEST, "유효하지 않은 알림 ID 목록입니다.");
Copy link

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 . -name "ErrorCode.java" -type f | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 1225


🏁 Script executed:

#!/bin/bash
# Extract error codes from all ErrorCode.java files and check for collisions

declare -A code_map

for file in $(find . -name "ErrorCode.java" -type f); do
    domain=$(echo "$file" | sed -E 's|.*domain/([^/]+)/.*|\1|; s|.*/(global|firebase|jwt|oci)/.*|\1|')
    
    # Extract all error code patterns: NAME(CODE, ...)
    codes=$(rg -o '\b\w+\((\d+),' "$file" | cut -d'(' -f2 | cut -d',' -f1)
    
    if [ ! -z "$codes" ]; then
        echo "=== Domain: $domain (File: $file) ==="
        echo "$codes" | while read code; do
            if [ ! -z "$code" ]; then
                key="$code"
                if [ ! -z "${code_map[$key]}" ]; then
                    echo "  COLLISION DETECTED: Code $code also in ${code_map[$key]}"
                else
                    code_map[$key]="$domain"
                fi
                echo "  $code"
            fi
        done
        echo ""
    fi
done

# Check for duplicates across all files
echo "=== Checking for duplicate codes across all domains ==="
rg -h -o '\b\w+\((\d+),' . --type=java -g '**/exception/ErrorCode.java' | cut -d'(' -f2 | cut -d',' -f1 | sort | uniq -d

Repository: our-campUS/our-campUS-BE

Length of output: 2916


🏁 Script executed:

# Get details on error code 2801 in review domain
cat -n ./src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java | grep -E "(2800|2801)"

Repository: our-campUS/our-campUS-BE

Length of output: 221


🏁 Script executed:

# Get details on error code 2801 in notification domain
cat -n ./src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java | grep -A2 -B2 "2801"

Repository: our-campUS/our-campUS-BE

Length of output: 390


🏁 Script executed:

# Verify mail and councilpost collision
cat -n ./src/main/java/com/campus/campus/domain/mail/application/exception/ErrorCode.java | head -15
cat -n ./src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 1619


알림 도메인의 에러 코드 변경 필요

NOTIFICATION_NOT_FOUND(2801)review 도메인의 NOT_REVIEW_WRITER(2801)과 중복되고 있습니다. 에러 코드는 도메인 간에 고유해야 로깅 및 디버깅 시 혼란을 방지할 수 있습니다.

notification 도메인의 에러 코드를 다음과 같이 변경하세요:

  • NOTIFICATION_NOT_FOUND(2901, ...)
  • NOTIFICATION_ACCESS_DENIED(2902, ...)
  • INVALID_NOTIFICATION_IDS(2903, ...)
🤖 Prompt for AI Agents
In
`@src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java`
around lines 14 - 16, Update the numeric codes for the notification domain enum
entries in ErrorCode so they no longer collide with the review domain: change
NOTIFICATION_NOT_FOUND from 2801 to 2901, NOTIFICATION_ACCESS_DENIED from 2802
to 2902, and INVALID_NOTIFICATION_IDS from 2803 to 2903 in the ErrorCode enum
definition (the constants NOTIFICATION_NOT_FOUND, NOTIFICATION_ACCESS_DENIED,
INVALID_NOTIFICATION_IDS).

Comment on lines 40 to 51
long weeks = days / 7;
if (weeks < 4) {
return weeks + "주 전";
}

long months = days / 30;
if (months < 12) {
return months + "개월 전";
}

long years = days / 365;
return years + "년 전";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

경계 조건에서 "0개월 전" 또는 "0년 전"이 반환될 수 있습니다.

주(weeks)와 월(months) 계산 로직에 불일치가 있습니다:

  • days = 28일 때: weeks = 4 (조건 weeks < 4 통과 못함), months = 28/30 = 0 → "0개월 전" 반환
  • days = 360일 때: months = 12 (조건 months < 12 통과 못함), years = 360/365 = 0 → "0년 전" 반환
🐛 경계 조건 수정 제안
 	long weeks = days / 7;
-	if (weeks < 4) {
+	if (days < 30) {
 		return weeks + "주 전";
 	}

 	long months = days / 30;
-	if (months < 12) {
+	if (days < 365) {
 		return months + "개월 전";
 	}

 	long years = days / 365;
 	return years + "년 전";
📝 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
long weeks = days / 7;
if (weeks < 4) {
return weeks + "주 전";
}
long months = days / 30;
if (months < 12) {
return months + "개월 전";
}
long years = days / 365;
return years + "년 전";
long weeks = days / 7;
if (days < 30) {
return weeks + "주 전";
}
long months = days / 30;
if (days < 365) {
return months + "개월 전";
}
long years = days / 365;
return years + "년 전";

@1winhyun 1winhyun merged commit 31881c4 into dev Jan 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨Feat 새로운 기능 개발 ♻️Refactor 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] 관리자가 유저에게 보상 지급 시 유저에게 알림이 가도록 구현

2 participants