Skip to content

Commit c25708c

Browse files
authored
[FEAT] 알림 도메인 및 알림 페이지 API (#326)
<!-- ## PR 제목 컨벤션 [TYPE] 설명 (#이슈번호) 예시: - [FEAT] 회원가입 API 구현 (#14) - [FIX] 이미지 업로드 시 NPE 수정 (#23) - [REFACTOR] 토큰 로직 분리 (#8) - [DOCS] ERD 스키마 업데이트 (#6) - [CHORE] CI/CD 파이프라인 추가 (#3) - [RELEASE] v1.0.0 배포 (#30) TYPE: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE, RENAME, REMOVE, RELEASE --> ## Summary <!-- 변경 사항을 간단히 설명해주세요 --> Notification 도메인과 알림 페이지 REST API를 구현했습니다. 푸시·이메일 발송은 다루지 않고, 저장·조회·필터·읽음 처리만 담당합니다. **목표**: 알림 레코드를 저장·조회·필터·읽음 처리할 수 있는 도메인과 API를 갖추고, 이후 서브 이슈(이벤트 발행·푸시·이메일)에서 이 API를 그대로 사용할 수 있도록 합니다. ## Changes <!-- 변경된 내용을 목록으로 작성해주세요 --> ### 도메인 계층 - ✅ `Notification` 엔티티 (UUID PK, DeleteBaseEntity 상속) - ✅ `NotificationKind` enum (8종: PROPOSAL_RECEIVED, CAMPAIGN_MATCHED 등) - ✅ `NotificationCategory` enum (UI 필터용: PROPOSAL, MATCHING, SETTLEMENT, CHAT) - ✅ `ReferenceType` enum (CAMPAIGN_PROPOSAL, CAMPAIGN_APPLY, CAMPAIGN) - ✅ `NotificationRepository` (JPQL 벌크 업데이트 포함) - ✅ `NotificationErrorCode` (404, 403) ### 애플리케이션 계층 - ✅ `NotificationService` (생성, 단건 읽음, 전체 읽기, 소프트 삭제) - ✅ `NotificationQueryService` (목록 조회, 미읽음 개수) - ✅ `CreateNotificationCommand` (Phase 2 이벤트 리스너용 내부 DTO) ### 프레젠테이션 계층 - ✅ `NotificationController` (4개 REST 엔드포인트) - ✅ `NotificationSwagger` 인터페이스 - ✅ Response DTO (record): `NotificationResponse`, `NotificationListResponse`, `NotificationDateGroup`, `ReadAllResponse`, `UnreadCountResponse` ### API 엔드포인트 1. **GET** `/api/v1/notifications` - 알림 목록 (filter, 페이징, 날짜 그룹) 2. **PATCH** `/api/v1/notifications/{id}/read` - 단건 읽음 처리 3. **PATCH** `/api/v1/notifications/read-all` - 전체 읽기 (벌크 UPDATE) 4. **GET** `/api/v1/notifications/unread-count` - 미읽음 개수 ## Type of Change <!-- 해당하는 항목에 x 표시해주세요 --> - [ ] Bug fix (기존 기능에 영향을 주지 않는 버그 수정) - [x] New feature (기존 기능에 영향을 주지 않는 새로운 기능 추가) - [ ] Breaking change (기존 기능에 영향을 주는 수정) - [ ] Refactoring (기능 변경 없는 코드 개선) - [ ] Documentation (문서 수정) - [ ] Chore (빌드, 설정 등 기타 변경) - [ ] Release (develop → main 배포) ## Related Issues <!-- 관련 이슈 번호를 작성해주세요 (예: Closes #123, Fixes #456) --> Closes #216 ## 참고 사항 <!-- 리뷰어가 알아야 할 추가 정보가 있다면 작성해주세요 -->
1 parent 80b67ea commit c25708c

16 files changed

+612
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.example.RealMatch.notification.application.dto;
2+
3+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
4+
import com.example.RealMatch.notification.domain.entity.enums.ReferenceType;
5+
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
9+
@Getter
10+
@Builder
11+
public class CreateNotificationCommand {
12+
13+
private final Long userId;
14+
private final NotificationKind kind;
15+
private final String title;
16+
private final String body;
17+
private final ReferenceType referenceType;
18+
private final String referenceId;
19+
private final Long campaignId;
20+
private final Long proposalId;
21+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.example.RealMatch.notification.application.service;
2+
3+
import java.time.format.DateTimeFormatter;
4+
import java.util.LinkedHashMap;
5+
import java.util.List;
6+
import java.util.Locale;
7+
import java.util.stream.Collectors;
8+
9+
import org.springframework.data.domain.Page;
10+
import org.springframework.data.domain.PageRequest;
11+
import org.springframework.data.domain.Sort;
12+
import org.springframework.stereotype.Service;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import com.example.RealMatch.global.exception.CustomException;
16+
import com.example.RealMatch.notification.domain.entity.Notification;
17+
import com.example.RealMatch.notification.domain.entity.enums.NotificationCategory;
18+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
19+
import com.example.RealMatch.notification.domain.repository.NotificationRepository;
20+
import com.example.RealMatch.notification.exception.NotificationErrorCode;
21+
import com.example.RealMatch.notification.presentation.dto.response.NotificationDateGroup;
22+
import com.example.RealMatch.notification.presentation.dto.response.NotificationListResponse;
23+
import com.example.RealMatch.notification.presentation.dto.response.NotificationResponse;
24+
25+
import lombok.RequiredArgsConstructor;
26+
27+
@Service
28+
@RequiredArgsConstructor
29+
@Transactional(readOnly = true)
30+
public class NotificationQueryService {
31+
32+
private final NotificationRepository notificationRepository;
33+
34+
private static final DateTimeFormatter DATE_LABEL_FORMATTER =
35+
DateTimeFormatter.ofPattern("yy.MM.dd (E)", Locale.KOREAN);
36+
37+
public NotificationListResponse getNotifications(Long userId, String filter, int page, int size) {
38+
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
39+
40+
List<NotificationKind> kinds = resolveKinds(filter);
41+
42+
Page<Notification> notificationPage;
43+
if (kinds == null) {
44+
notificationPage = notificationRepository.findByUserId(userId, pageRequest);
45+
} else {
46+
notificationPage = notificationRepository.findByUserIdAndKindIn(userId, kinds, pageRequest);
47+
}
48+
49+
List<NotificationResponse> items = notificationPage.getContent().stream()
50+
.map(NotificationResponse::from)
51+
.toList();
52+
53+
List<NotificationDateGroup> groups = buildDateGroups(notificationPage.getContent());
54+
55+
long unreadCount = notificationRepository.countUnreadByUserId(userId);
56+
57+
return new NotificationListResponse(
58+
items,
59+
groups,
60+
unreadCount,
61+
notificationPage.getTotalElements(),
62+
notificationPage.getTotalPages(),
63+
notificationPage.getNumber(),
64+
notificationPage.getSize()
65+
);
66+
}
67+
68+
public long getUnreadCount(Long userId) {
69+
return notificationRepository.countUnreadByUserId(userId);
70+
}
71+
72+
private List<NotificationKind> resolveKinds(String filter) {
73+
if (filter == null || "ALL".equalsIgnoreCase(filter)) {
74+
return null;
75+
}
76+
77+
try {
78+
NotificationCategory category = NotificationCategory.valueOf(filter.toUpperCase());
79+
return category.getKinds();
80+
} catch (IllegalArgumentException e) {
81+
throw new CustomException(NotificationErrorCode.NOTIFICATION_INVALID_FILTER);
82+
}
83+
}
84+
85+
private List<NotificationDateGroup> buildDateGroups(List<Notification> notifications) {
86+
return notifications.stream()
87+
.collect(Collectors.groupingBy(
88+
n -> n.getCreatedAt().toLocalDate(),
89+
LinkedHashMap::new,
90+
Collectors.counting()
91+
))
92+
.entrySet().stream()
93+
.map(entry -> NotificationDateGroup.of(
94+
entry.getKey(),
95+
entry.getValue().intValue(),
96+
DATE_LABEL_FORMATTER))
97+
.toList();
98+
}
99+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.example.RealMatch.notification.application.service;
2+
3+
import java.util.UUID;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
import com.example.RealMatch.global.exception.CustomException;
9+
import com.example.RealMatch.notification.application.dto.CreateNotificationCommand;
10+
import com.example.RealMatch.notification.domain.entity.Notification;
11+
import com.example.RealMatch.notification.domain.repository.NotificationRepository;
12+
import com.example.RealMatch.notification.exception.NotificationErrorCode;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Transactional
19+
public class NotificationService {
20+
21+
private final NotificationRepository notificationRepository;
22+
23+
public Notification create(CreateNotificationCommand command) {
24+
Notification notification = Notification.builder()
25+
.userId(command.getUserId())
26+
.kind(command.getKind())
27+
.title(command.getTitle())
28+
.body(command.getBody())
29+
.referenceType(command.getReferenceType())
30+
.referenceId(command.getReferenceId())
31+
.campaignId(command.getCampaignId())
32+
.proposalId(command.getProposalId())
33+
.build();
34+
35+
return notificationRepository.save(notification);
36+
}
37+
38+
public void markAsRead(Long userId, UUID notificationId) {
39+
Notification notification = findNotificationForUser(userId, notificationId);
40+
notification.markAsRead();
41+
}
42+
43+
public int markAllAsRead(Long userId) {
44+
return notificationRepository.markAllAsRead(userId);
45+
}
46+
47+
public void softDelete(Long userId, UUID notificationId) {
48+
Notification notification = findNotificationForUser(userId, notificationId);
49+
notification.softDelete();
50+
}
51+
52+
private Notification findNotificationForUser(Long userId, UUID notificationId) {
53+
Notification notification = notificationRepository.findById(notificationId)
54+
.orElseThrow(() -> new CustomException(NotificationErrorCode.NOTIFICATION_NOT_FOUND));
55+
56+
if (!notification.getUserId().equals(userId)) {
57+
throw new CustomException(NotificationErrorCode.NOTIFICATION_FORBIDDEN);
58+
}
59+
60+
return notification;
61+
}
62+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.example.RealMatch.notification.domain.entity;
2+
3+
import java.util.UUID;
4+
5+
import com.example.RealMatch.global.common.DeleteBaseEntity;
6+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
7+
import com.example.RealMatch.notification.domain.entity.enums.ReferenceType;
8+
9+
import jakarta.persistence.Column;
10+
import jakarta.persistence.Entity;
11+
import jakarta.persistence.EnumType;
12+
import jakarta.persistence.Enumerated;
13+
import jakarta.persistence.GeneratedValue;
14+
import jakarta.persistence.GenerationType;
15+
import jakarta.persistence.Id;
16+
import jakarta.persistence.Index;
17+
import jakarta.persistence.Table;
18+
import lombok.AccessLevel;
19+
import lombok.Builder;
20+
import lombok.Getter;
21+
import lombok.NoArgsConstructor;
22+
23+
@Entity
24+
@Table(name = "notification", indexes = {
25+
@Index(name = "idx_notification_user_read_created", columnList = "user_id, is_read, created_at"),
26+
@Index(name = "idx_notification_user_created", columnList = "user_id, created_at"),
27+
@Index(name = "idx_notification_user_kind", columnList = "user_id, kind")
28+
})
29+
@Getter
30+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
31+
public class Notification extends DeleteBaseEntity {
32+
33+
@Id
34+
@GeneratedValue(strategy = GenerationType.UUID)
35+
@Column(columnDefinition = "BINARY(16)")
36+
private UUID id;
37+
38+
@Column(name = "user_id", nullable = false)
39+
private Long userId;
40+
41+
@Enumerated(EnumType.STRING)
42+
@Column(name = "kind", nullable = false, length = 30)
43+
private NotificationKind kind;
44+
45+
@Column(name = "title", nullable = false)
46+
private String title;
47+
48+
@Column(name = "body", nullable = false, length = 1000)
49+
private String body;
50+
51+
@Enumerated(EnumType.STRING)
52+
@Column(name = "reference_type", length = 30)
53+
private ReferenceType referenceType;
54+
55+
@Column(name = "reference_id", length = 36)
56+
private String referenceId;
57+
58+
@Column(name = "campaign_id")
59+
private Long campaignId;
60+
61+
@Column(name = "proposal_id")
62+
private Long proposalId;
63+
64+
@Column(name = "is_read", nullable = false)
65+
private boolean isRead = false;
66+
67+
@Builder
68+
protected Notification(Long userId, NotificationKind kind, String title, String body,
69+
ReferenceType referenceType, String referenceId,
70+
Long campaignId, Long proposalId) {
71+
this.userId = userId;
72+
this.kind = kind;
73+
this.title = title;
74+
this.body = body;
75+
this.referenceType = referenceType;
76+
this.referenceId = referenceId;
77+
this.campaignId = campaignId;
78+
this.proposalId = proposalId;
79+
}
80+
81+
public void markAsRead() {
82+
this.isRead = true;
83+
}
84+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.example.RealMatch.notification.domain.entity.enums;
2+
3+
import java.util.Arrays;
4+
import java.util.List;
5+
6+
public enum NotificationCategory {
7+
8+
PROPOSAL,
9+
MATCHING,
10+
SETTLEMENT,
11+
CHAT;
12+
13+
public List<NotificationKind> getKinds() {
14+
return Arrays.stream(NotificationKind.values())
15+
.filter(kind -> kind.getCategory() == this)
16+
.toList();
17+
}
18+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.example.RealMatch.notification.domain.entity.enums;
2+
3+
public enum NotificationKind {
4+
5+
// 제안 관련 (PROPOSAL 카테고리)
6+
PROPOSAL_RECEIVED(NotificationCategory.PROPOSAL),
7+
PROPOSAL_SENT(NotificationCategory.PROPOSAL),
8+
CAMPAIGN_APPLIED(NotificationCategory.PROPOSAL),
9+
10+
// 매칭 관련 (MATCHING 카테고리)
11+
CAMPAIGN_MATCHED(NotificationCategory.MATCHING),
12+
AUTO_CONFIRMED(NotificationCategory.MATCHING),
13+
14+
// 정산 관련 (SETTLEMENT 카테고리)
15+
CAMPAIGN_COMPLETED(NotificationCategory.SETTLEMENT),
16+
SETTLEMENT_READY(NotificationCategory.SETTLEMENT),
17+
18+
// 채팅 (CHAT 카테고리)
19+
CHAT_MESSAGE(NotificationCategory.CHAT);
20+
21+
private final NotificationCategory category;
22+
23+
NotificationKind(NotificationCategory category) {
24+
this.category = category;
25+
}
26+
27+
public NotificationCategory getCategory() {
28+
return category;
29+
}
30+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.example.RealMatch.notification.domain.entity.enums;
2+
3+
public enum ReferenceType {
4+
5+
CAMPAIGN_PROPOSAL,
6+
CAMPAIGN_APPLY,
7+
CAMPAIGN
8+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.example.RealMatch.notification.domain.repository;
2+
3+
import java.util.Collection;
4+
import java.util.UUID;
5+
6+
import org.springframework.data.domain.Page;
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.data.jpa.repository.Modifying;
10+
import org.springframework.data.jpa.repository.Query;
11+
import org.springframework.data.repository.query.Param;
12+
13+
import com.example.RealMatch.notification.domain.entity.Notification;
14+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
15+
16+
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
17+
18+
Page<Notification> findByUserId(Long userId, Pageable pageable);
19+
20+
Page<Notification> findByUserIdAndKindIn(Long userId, Collection<NotificationKind> kinds, Pageable pageable);
21+
22+
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.isRead = false AND n.isDeleted = false")
23+
long countUnreadByUserId(@Param("userId") Long userId);
24+
25+
/**
26+
* 해당 유저의 미읽음 알림을 모두 읽음 처리한다 (벌크 UPDATE)
27+
*/
28+
@Modifying(clearAutomatically = true)
29+
@Query("UPDATE Notification n SET n.isRead = true WHERE n.userId = :userId AND n.isRead = false AND n.isDeleted = false")
30+
int markAllAsRead(@Param("userId") Long userId);
31+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.example.RealMatch.notification.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import com.example.RealMatch.global.presentation.code.BaseErrorCode;
6+
7+
import lombok.Getter;
8+
import lombok.RequiredArgsConstructor;
9+
10+
@Getter
11+
@RequiredArgsConstructor
12+
public enum NotificationErrorCode implements BaseErrorCode {
13+
14+
NOTIFICATION_INVALID_FILTER(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_1", "유효하지 않은 필터 값입니다."),
15+
NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_1", "알림을 찾을 수 없습니다."),
16+
NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_403_1", "해당 알림에 대한 권한이 없습니다.");
17+
18+
private final HttpStatus status;
19+
private final String code;
20+
private final String message;
21+
}

0 commit comments

Comments
 (0)