diff --git a/build.gradle.kts b/build.gradle.kts index 42a6f52a..4d75fad7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,11 @@ subprojects { // compileOnly 어노테이션 전용 — 경계 검증은 bootstrap 테스트가 수행한다. "compileOnly"(platform(rootProject.libs.springModulithBom)) "compileOnly"(rootProject.libs.springModulithApi) + + // 로깅은 도메인 모듈을 포함한 모든 모듈이 Spring Boot 기본 구성(SLF4J 파사드 + Logback)을 쓰도록 공통 적용. + // 버전은 Boot BOM이 관리한다 + "implementation"(platform(rootProject.libs.springBootDependenciesBom)) + "implementation"(rootProject.libs.springBootStarterLogging) } tasks.withType { diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java new file mode 100644 index 00000000..8252adc7 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java @@ -0,0 +1,79 @@ +package kr.ac.kookmin.stream.common; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 조사 유틸. 문구 템플릿의 {@code %s}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를 + * 앞 단어의 받침에 맞는 조사 하나로 바꾼다. + *

+ * 앞 단어는 따옴표·괄호·문장부호·공백을 건너뛴 마지막 글자로 판단한다("'봄 축제!'을(를)" → "'봄 축제!'를"). + * 한글 음절과 숫자만 판단하고, 영문처럼 판단할 수 없으면 "을(를)" 표기를 그대로 둔다. + * 값(행사명 등) 안에 같은 표기가 있어도 바꾸지 않도록 템플릿 부분만 바꾼다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class JosaUtil { + + private static final String PLACEHOLDER = "%s"; + private static final Pattern PARTICLE = Pattern.compile("(을|이|은|과)\\((를|가|는|와)\\)"); + // 0(영)·1(일)·3(삼)·6(육)·7(칠)·8(팔)은 받침이 있다 + private static final String DIGITS_WITH_FINAL_CONSONANT = "013678"; + + private static final char HANGUL_SYLLABLE_FIRST = '가'; + private static final char HANGUL_SYLLABLE_LAST = '힣'; + private static final int FINAL_CONSONANT_COUNT = 28; + + public static String format(String template, List values) { + String[] segments = template.split(PLACEHOLDER, -1); + StringBuilder formatted = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + appendResolved(formatted, segments[i]); + if (i < segments.length - 1) { + formatted.append(values.get(i)); + } + } + return formatted.toString(); + } + + // 조사 표기 앞의 단어는 이미 붙여 둔 앞부분과 이 조각에서 표기 앞까지를 이어서 본다 + private static void appendResolved(StringBuilder formatted, String segment) { + Matcher matcher = PARTICLE.matcher(segment); + int copiedUntil = 0; + while (matcher.find()) { + formatted.append(segment, copiedUntil, matcher.start()); + Boolean hasFinalConsonant = hasFinalConsonant(lastLetterOrDigit(formatted)); + formatted.append(hasFinalConsonant == null + ? matcher.group() + : hasFinalConsonant ? matcher.group(1) : matcher.group(2)); + copiedUntil = matcher.end(); + } + formatted.append(segment, copiedUntil, segment.length()); + } + + private static Character lastLetterOrDigit(CharSequence text) { + for (int i = text.length() - 1; i >= 0; i--) { + char c = text.charAt(i); + if (Character.isLetterOrDigit(c)) { + return c; + } + } + return null; + } + + // 판단할 수 없으면 null + private static Boolean hasFinalConsonant(Character c) { + if (c == null) { + return null; + } + if (c >= HANGUL_SYLLABLE_FIRST && c <= HANGUL_SYLLABLE_LAST) { + return (c - HANGUL_SYLLABLE_FIRST) % FINAL_CONSONANT_COUNT != 0; + } + if (Character.isDigit(c)) { + return DIGITS_WITH_FINAL_CONSONANT.indexOf(c) >= 0; + } + return null; + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java index 3e0ac35a..866b842c 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java @@ -34,6 +34,15 @@ public static MemberNotificationSetting of( return new MemberNotificationSetting(id, memberId, rentalEnabled, eventEnabled, lockerEnabled, noticeEnabled); } + public boolean isEnabled(NotificationCategory category) { + return switch (category) { + case BILLILGE -> rentalEnabled; + case EVENT -> eventEnabled; + case LOCKER -> lockerEnabled; + case NOTICE -> noticeEnabled; + }; + } + // 바뀐 값이 있으면 true를 돌려준다 public boolean update(MemberNotificationSettingUpdateCommand command) { if (this.rentalEnabled == command.rentalEnabled() diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java index dff51e4a..2c1dd001 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java @@ -1,28 +1,48 @@ package kr.ac.kookmin.stream.member.domain.notification.domain; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; +/** + * 알림함의 알림 한 건. 문구는 저장하지 않고 종류와 문구 값으로 다시 만든다({@link NotificationType}). + */ @Getter @EqualsAndHashCode @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Notification { + // 문구 값을 한 컬럼에 순서대로 이어 붙일 때 쓰는 구분자. 행사명에 쉼표가 들어갈 수 있어 쉼표 대신 단위 구분 문자(US)를 쓴다 + private static final String FORMAT_VALUE_DELIMITER = "\u001F"; + private Long id; private Long memberId; - private String type; + private NotificationType type; + // 문구 값을 순서대로 이어 붙인 문자열. 값이 없으면 null private String formatValues; + // 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지는 type이 정한다 + private Long targetId; private boolean read; + public static Notification create(Long memberId, NotificationType type, List formatValues, Long targetId) { + String joined = formatValues.isEmpty() ? null : String.join(FORMAT_VALUE_DELIMITER, formatValues); + return new Notification(null, memberId, type, joined, targetId, false); + } + public static Notification of( Long id, Long memberId, - String type, + NotificationType type, String formatValues, + Long targetId, boolean read ) { - return new Notification(id, memberId, type, formatValues, read); + return new Notification(id, memberId, type, formatValues, targetId, read); + } + + public List getFormatValueList() { + return formatValues == null ? List.of() : List.of(formatValues.split(FORMAT_VALUE_DELIMITER, -1)); } } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java new file mode 100644 index 00000000..f3497d98 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +/** + * 알림 카테고리. 알림 설정의 수신 여부를 고르는 기준이고, 푸시 data의 {@code category} 값으로 앱에 그대로 내려간다. + */ +public enum NotificationCategory { + BILLILGE, // 빌릴게 (rental_enabled) + EVENT, // 행사 (event_enabled) + LOCKER, // 사물함 (locker_enabled) + NOTICE // 게시판: 새 공지, 열린피드백 답변 (notice_enabled) +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java new file mode 100644 index 00000000..7c16e36a --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +import java.util.List; +import kr.ac.kookmin.stream.common.JosaUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 알림 종류와 문구. 제목은 문구표의 굵은 첫 문장, 본문은 둘째 문장이다. 한 문장짜리는 본문이 없다. + *

+ * 문구의 {@code %s}에는 알림을 만들 때 넘긴 값 목록이 앞에서부터 들어간다. 제목과 본문은 같은 값 목록을 각각 처음부터 쓴다. + * 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link JosaUtil}). + * 상수 이름이 {@code notifications.type}에 저장되므로 이름을 바꾸면 쌓인 알림함 행을 읽지 못한다. + */ +@Getter +@AllArgsConstructor +public enum NotificationType { + + // 값: 행사명 + EVENT_APPLIED(NotificationCategory.EVENT, "'%s'을(를) 신청했어요.", "신청 내역에서 자세한 내용을 확인할 수 있어요."), + EVENT_APPLICATION_CANCELED(NotificationCategory.EVENT, "'%s' 참여 신청을 취소했어요.", null), + EVENT_STARTING_TOMORROW(NotificationCategory.EVENT, "'%s'이(가) 내일 열려요.", "행사 일정과 장소를 확인해 주세요."), + EVENT_APPLY_CLOSING(NotificationCategory.EVENT, "'%s' 신청이 곧 마감돼요.", "참여하려면 기간 내에 신청해 주세요."), + + // 값: 없음 + LOCKER_ASSIGNED(NotificationCategory.LOCKER, "사물함을 신청했어요.", "배정된 사물함 번호를 확인해 주세요."), + LOCKER_APPLY_CLOSING(NotificationCategory.LOCKER, "사물함 신청이 내일 마감돼요.", "이용하려면 기간 내에 신청해 주세요."), + LOCKER_USAGE_ENDING(NotificationCategory.LOCKER, "사물함 이용 기간이 얼마 남지 않았어요.", "만료 전에 개인 물품을 미리 정리해 주세요."), + + // 값: 물품명 + RENTAL_DUE_SOON(NotificationCategory.BILLILGE, "반납까지 1시간 남았어요.", "늦지 않게 %s을(를) 반납해 주세요."), + RENTAL_OVERDUE(NotificationCategory.BILLILGE, "반납 기한이 지났어요.", "학생회실에 방문해 %s을(를) 반납해 주세요."), + + // 값: 없음 + NOTICE_CREATED(NotificationCategory.NOTICE, "새로운 공지사항이 올라왔어요.", "자세한 내용을 확인해 주세요."); + + private final NotificationCategory category; + private final String title; + private final String body; + + public String formatTitle(List formatValues) { + return JosaUtil.format(title, formatValues); + } + + // 본문이 없는 알림은 null + public String formatBody(List formatValues) { + return body == null ? null : JosaUtil.format(body, formatValues); + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java new file mode 100644 index 00000000..969a7e94 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +/** + * 푸시를 받을 수 있는 회원(토큰이 있는 활성 회원)과 그 회원의 알림 설정. + */ +public record PushTarget(Long memberId, String fcmToken, MemberNotificationSetting setting) { + + public boolean accepts(NotificationCategory category) { + return setting.isEnabled(category); + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java new file mode 100644 index 00000000..1872a99b --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.member.domain.notification.repository; + +import java.util.Collection; +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget; + +/** + * 알림 수신자 조회. 알림함 대상은 활성 회원이고, 푸시 대상은 그중 토큰과 알림 설정이 있는 회원이다. + * FCM이 무효라고 응답한 토큰 정리도 여기서 한다. + */ +public interface NotificationRecipientRepository { + List findAllActiveMemberIds(); + List findPushTargets(Collection memberIds); + List findAllPushTargets(); + int clearFcmTokens(Collection fcmTokens); +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..17a64b11 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.member.domain.notification.repository; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; + +public interface NotificationRepository { + void saveAll(List notifications); +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java new file mode 100644 index 00000000..7a93f98d --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java @@ -0,0 +1,107 @@ +package kr.ac.kookmin.stream.member.domain.notification.service.impl; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.member.domain.notification.client.PushNotificationClient; +import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; +import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushMessage; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendResult; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendStatus; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget; +import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRecipientRepository; +import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 알림 한 건을 수신자에게 보낸다. 알림함에는 수신자 전원에게 저장하고, 푸시는 그 카테고리를 켜 두고 토큰이 있는 회원에게만 보낸다. + *

+ * 아웃박스 릴레이가 부르는 리스너에서 트랜잭션 없이 호출된다. 알림함 저장은 {@code saveAll}의 트랜잭션으로 한꺼번에 반영하고, + * 저장이 끝난 뒤 트랜잭션 밖에서 푸시를 보낸다. 알림함 저장이 실패하면 예외가 리스너까지 올라가 아웃박스가 다시 발행한다. + */ +@Component +@RequiredArgsConstructor +class NotificationDispatcher { + + private static final Logger log = LoggerFactory.getLogger(NotificationDispatcher.class); + + private final NotificationRepository notificationRepository; + private final NotificationRecipientRepository notificationRecipientRepository; + private final PushNotificationClient pushNotificationClient; + + /** 지정한 회원에게 보낸다. */ + void dispatch(NotificationType type, List formatValues, long targetId, Collection memberIds) { + List recipients = List.copyOf(new LinkedHashSet<>(memberIds)); + List pushTargets = notificationRecipientRepository.findPushTargets(recipients); + send(type, formatValues, targetId, recipients, pushTargets); + } + + /** 활성 회원 전체에서 제외할 회원(예: 이미 신청한 회원)을 뺀 나머지에게 보낸다. */ + void dispatchToAll(NotificationType type, List formatValues, long targetId, Collection excludedMemberIds) { + Set excluded = Set.copyOf(excludedMemberIds); + List recipients = notificationRecipientRepository.findAllActiveMemberIds().stream() + .filter(memberId -> !excluded.contains(memberId)) + .toList(); + List pushTargets = notificationRecipientRepository.findAllPushTargets().stream() + .filter(target -> !excluded.contains(target.memberId())) + .toList(); + send(type, formatValues, targetId, recipients, pushTargets); + } + + private void send( + NotificationType type, + List formatValues, + long targetId, + List recipients, + List pushTargets + ) { + if (recipients.isEmpty()) { + return; + } + notificationRepository.saveAll(recipients.stream() + .map(memberId -> Notification.create(memberId, type, formatValues, targetId)) + .toList()); + + // 한 기기의 토큰이 여러 회원 행에 남아 있을 수 있어 토큰 단위로 중복을 지운다. 그대로 보내면 같은 기기에 여러 번 간다 + List tokens = pushTargets.stream() + .filter(target -> target.accepts(type.getCategory())) + .map(PushTarget::fcmToken) + .distinct() + .toList(); + int invalidTokenCount = 0; + if (!tokens.isEmpty()) { + PushSendResult result = pushNotificationClient.send(tokens, toPushMessage(type, formatValues, targetId)); + invalidTokenCount = clearInvalidTokens(result.tokensWith(PushSendStatus.INVALID_TOKEN)); + } + log.info("알림 발송: type={}, targetId={}, recipientCount={}, pushTokenCount={}, invalidTokenCount={}", + type, targetId, recipients.size(), tokens.size(), invalidTokenCount); + } + + private PushMessage toPushMessage(NotificationType type, List formatValues, long targetId) { + Map data = Map.of( + "type", type.name(), + "category", type.getCategory().name(), + "targetId", String.valueOf(targetId) + ); + return new PushMessage(type.formatTitle(formatValues), type.formatBody(formatValues), data); + } + + // 푸시는 이미 나갔으므로 정리가 실패해도 예외를 던지지 않는다. 던지면 아웃박스가 다시 발행해 같은 알림이 한 번 더 간다 + private int clearInvalidTokens(List invalidTokens) { + if (invalidTokens.isEmpty()) { + return 0; + } + try { + notificationRecipientRepository.clearFcmTokens(invalidTokens); + } catch (RuntimeException e) { + log.warn("무효 FCM 토큰 정리 실패: tokenCount={}", invalidTokens.size(), e); + } + return invalidTokens.size(); + } +} diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index fa3b17ca..e0dabab2 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -374,6 +374,8 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom ## 7. 모듈별 허용 의존성 +모든 모듈은 루트 `build.gradle.kts`의 `subprojects`에서 Lombok(compileOnly), `spring-modulith-api`(compileOnly), `spring-boot-starter-logging`(SLF4J 파사드 + Logback)을 공통으로 받는다. 아래 표는 그 밖의 의존성이다. 모듈별 `build.gradle.kts`에 공통 의존성을 다시 선언하지 않는다. + | 모듈 | 허용 의존성 | | --- | --- | | `bootstrap` | 모든 `api:*` + `gateway:*` + `infrastructure:*` + `core:common` + Spring Boot 실행(Actuator, 스케줄링) + `spring-modulith-core`·ArchUnit(**test scope**) | diff --git a/gateway/logging/build.gradle.kts b/gateway/logging/build.gradle.kts index 50b588d2..ca9a0953 100644 --- a/gateway/logging/build.gradle.kts +++ b/gateway/logging/build.gradle.kts @@ -11,7 +11,6 @@ dependencies { implementation(libs.springWeb) implementation(libs.springContext) implementation(libs.jakartaServletApi) - implementation(libs.slf4jApi) testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(libs.springBootStarterTest) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 685b5e6f..d2d1d626 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ springBootStarterSecurity = { module = "org.springframework.boot:spring-boot-sta springBootStarterDataJpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" } springBootStarterFlyway = { module = "org.springframework.boot:spring-boot-starter-flyway" } springBootStarterJackson = { module = "org.springframework.boot:spring-boot-starter-jackson" } +springBootStarterLogging = { module = "org.springframework.boot:spring-boot-starter-logging" } springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" } springBootStarterSecurityTest = { module = "org.springframework.boot:spring-boot-starter-security-test" } springBootStarterDataJpaTest = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" } @@ -48,7 +49,6 @@ springJdbc = { module = "org.springframework:spring-jdbc" } jakartaServletApi = { module = "jakarta.servlet:jakarta.servlet-api" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } -slf4jApi = { module = "org.slf4j:slf4j-api" } flywayMysql = { module = "org.flywaydb:flyway-mysql" } mysqlConnectorJ = { module = "com.mysql:mysql-connector-j" } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java index 0fd828a2..287eeb7f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java @@ -1,11 +1,13 @@ package kr.ac.kookmin.stream.db.member; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; public interface MemberJpaRepository extends JpaRepository { @@ -24,6 +26,29 @@ public interface MemberJpaRepository extends JpaRepository searchIdsByKeyword(@Param("keyword") String keyword); + @Query("SELECT m.id FROM MemberJpaEntity m WHERE m.isDeleted = false") + List findAllActiveIds(); + + // 알림 설정이 없는 회원(로그인만 하고 가입을 마치지 않은 회원)은 푸시 대상에서 빠진다 + @Query(""" + SELECT new kr.ac.kookmin.stream.db.member.PushTargetProjection(m.id, m.fcmToken, s) + FROM MemberJpaEntity m + JOIN MemberNotificationSettingJpaEntity s ON s.memberId = m.id + WHERE m.isDeleted = false + AND m.fcmToken IS NOT NULL + AND m.id IN :memberIds + """) + List findPushTargetsByIdIn(@Param("memberIds") Collection memberIds); + + @Query(""" + SELECT new kr.ac.kookmin.stream.db.member.PushTargetProjection(m.id, m.fcmToken, s) + FROM MemberJpaEntity m + JOIN MemberNotificationSettingJpaEntity s ON s.memberId = m.id + WHERE m.isDeleted = false + AND m.fcmToken IS NOT NULL + """) + List findAllPushTargets(); + // 탈퇴 회원에게는 어차피 보내지 않으므로 is_deleted와 관계없이 지운다 @Modifying(clearAutomatically = true) @Query(""" @@ -33,4 +58,15 @@ public interface MemberJpaRepository extends JpaRepository :memberId """) void clearFcmTokenOfOthers(@Param("memberId") Long memberId, @Param("fcmToken") String fcmToken); + + // 발송 이후 새로 등록된 토큰까지 지우지 않도록 회원 ID가 아니라 FCM이 무효라고 한 토큰 값으로 지운다. + // 알림 발송 처리는 트랜잭션 밖에서 돌기 때문에 여기서 연다 + @Transactional + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE MemberJpaEntity m + SET m.fcmToken = NULL + WHERE m.fcmToken IN :fcmTokens + """) + int clearFcmTokens(@Param("fcmTokens") Collection fcmTokens); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java index 66571a95..231b6892 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java @@ -2,6 +2,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -9,6 +11,7 @@ import jakarta.persistence.Table; import kr.ac.kookmin.stream.db.common.BaseCreatedTimeEntity; import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; +import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -30,12 +33,16 @@ public class NotificationJpaEntity extends BaseCreatedTimeEntity { @Column(name = "member_id", nullable = false) private Long memberId; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 50) - private String type; + private NotificationType type; @Column(name = "format_values") private String formatValues; + @Column(name = "target_id") + private Long targetId; + @Column(name = "is_read", nullable = false) private boolean read; @@ -44,6 +51,7 @@ private NotificationJpaEntity(Notification notification) { this.memberId = notification.getMemberId(); this.type = notification.getType(); this.formatValues = notification.getFormatValues(); + this.targetId = notification.getTargetId(); this.read = notification.isRead(); } @@ -52,6 +60,6 @@ public static NotificationJpaEntity from(Notification notification) { } public Notification toDomain() { - return Notification.of(id, memberId, type, formatValues, read); + return Notification.of(id, memberId, type, formatValues, targetId, read); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java new file mode 100644 index 00000000..e40cc462 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.db.member; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationJpaRepository extends JpaRepository { +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java new file mode 100644 index 00000000..46c8931b --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java @@ -0,0 +1,47 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.Collection; +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget; +import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRecipientRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NotificationRecipientRepositoryImpl implements NotificationRecipientRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public List findAllActiveMemberIds() { + return memberJpaRepository.findAllActiveIds(); + } + + @Override + public List findPushTargets(Collection memberIds) { + if (memberIds.isEmpty()) { + return List.of(); + } + return toPushTargets(memberJpaRepository.findPushTargetsByIdIn(memberIds)); + } + + @Override + public List findAllPushTargets() { + return toPushTargets(memberJpaRepository.findAllPushTargets()); + } + + @Override + public int clearFcmTokens(Collection fcmTokens) { + if (fcmTokens.isEmpty()) { + return 0; + } + return memberJpaRepository.clearFcmTokens(fcmTokens); + } + + private List toPushTargets(List projections) { + return projections.stream() + .map(p -> new PushTarget(p.memberId(), p.fcmToken(), p.setting().toDomain())) + .toList(); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java new file mode 100644 index 00000000..8a54a2a2 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; +import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public void saveAll(List notifications) { + notificationJpaRepository.saveAll(notifications.stream() + .map(NotificationJpaEntity::from) + .toList()); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java new file mode 100644 index 00000000..fb005813 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java @@ -0,0 +1,3 @@ +package kr.ac.kookmin.stream.db.member; + +public record PushTargetProjection(Long memberId, String fcmToken, MemberNotificationSettingJpaEntity setting) {} diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql b/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql new file mode 100644 index 00000000..432a561a --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql @@ -0,0 +1,4 @@ +-- 알림함에서 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지(행사·사물함 회차·공지·대여 이력)는 type이 정한다. +-- 대상이 없는 알림도 생길 수 있어 NULL을 허용한다. +ALTER TABLE notifications + ADD COLUMN target_id BIGINT NULL AFTER format_values;