From 99a99faf5e720733da51601b0a182b54fe428dc3 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:26:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20FCM=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EC=B2=98=EB=A6=AC=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 --- .../stream/member/domain/member/domain/Member.java | 9 +++++++++ .../domain/member/repository/MemberRepository.java | 1 + .../member/domain/member/service/MemberService.java | 1 + .../member/service/impl/MemberServiceImpl.java | 12 ++++++++++++ .../stream/db/member/MemberJpaRepository.java | 11 +++++++++++ .../stream/db/member/MemberRepositoryImpl.java | 5 +++++ 6 files changed, 39 insertions(+) diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java index b273e031..158bc599 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java @@ -67,4 +67,13 @@ public void registerPhoneNumber(String phoneNumber) { public boolean hasPhoneNumber() { return phoneNumber != null; } + + // 앱이 Firebase에서 받은 등록 토큰으로 바꾼다. 바뀐 값이 있으면 true를 돌려준다 + public boolean updateFcmToken(String fcmToken) { + if (Objects.equals(this.fcmToken, fcmToken)) { + return false; + } + this.fcmToken = fcmToken; + return true; + } } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java index d2e74f8a..bd0465e3 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java @@ -11,4 +11,5 @@ public interface MemberRepository { List findAllByIds(List ids); List searchIdsByKeyword(String keyword); Member save(Member member); + void clearFcmTokenOfOthers(Long memberId, String fcmToken); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index 0c584bc8..39d1a0fd 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -15,4 +15,5 @@ public interface MemberService { Member registerOrUpdateByStudentId(MemberProfileCommand command); Member signUp(Long memberId, MemberSignUpCommand command); boolean isSignedUp(Long memberId); + void updateFcmToken(Long memberId, String fcmToken); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index a8ad7c32..f6bdf2d2 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -83,6 +83,18 @@ public boolean isSignedUp(Long memberId) { return isSignedUp(getById(memberId)); } + // 한 기기를 다른 계정으로 쓰게 되면 이전 회원의 알림이 새 사용자에게 가지 않도록, 다른 회원에게 남은 같은 토큰을 지운다. + // 이미 중복이 들어 있는 데이터도 정리하려고 자기 토큰이 그대로여도 매번 지운다 + @Override + @Transactional + public void updateFcmToken(Long memberId, String fcmToken) { + Member member = getById(memberId); + memberRepository.clearFcmTokenOfOthers(memberId, fcmToken); + if (member.updateFcmToken(fcmToken)) { + memberRepository.save(member); + } + } + // 전화번호와 필수 약관 동의가 모두 있어야 가입을 마친 것으로 본다 private boolean isSignedUp(Member member) { return member.hasPhoneNumber() && memberTermService.hasAgreedRequiredTerms(member.getId()); 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 a1b224b7..0fd828a2 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 @@ -3,6 +3,7 @@ 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; @@ -22,4 +23,14 @@ public interface MemberJpaRepository extends JpaRepository searchIdsByKeyword(@Param("keyword") String keyword); + + // 탈퇴 회원에게는 어차피 보내지 않으므로 is_deleted와 관계없이 지운다 + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE MemberJpaEntity m + SET m.fcmToken = NULL + WHERE m.fcmToken = :fcmToken + AND m.id <> :memberId + """) + void clearFcmTokenOfOthers(@Param("memberId") Long memberId, @Param("fcmToken") String fcmToken); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java index 9996c87a..0dba47a5 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java @@ -61,4 +61,9 @@ public Member save(Member member) { throw e; } } + + @Override + public void clearFcmTokenOfOthers(Long memberId, String fcmToken) { + memberJpaRepository.clearFcmTokenOfOthers(memberId, fcmToken); + } } From 5eb6fe1274f8371a7112853658db0f7a1815ec1e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:26:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/core/member/AppMemberApi.java | 12 +++++++++++- .../api/app/core/member/AppMemberController.java | 12 ++++++++++++ .../core/member/request/FcmTokenUpdateRequest.java | 13 +++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/FcmTokenUpdateRequest.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java index 9fd73ee0..a13b436c 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.member.request.FcmTokenUpdateRequest; import kr.ac.kookmin.stream.api.app.core.member.request.MemberSignUpRequest; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; @@ -12,7 +13,7 @@ /** * 학생 앱 회원 API의 문서 명세. 구현은 {@link AppMemberController}가 맡는다. */ -@Tag(name = "회원", description = "학생 앱 회원가입") +@Tag(name = "회원", description = "학생 앱 회원가입·FCM 토큰 등록") public interface AppMemberApi { /** 회원가입. 로그인 응답의 signUpRequired가 true인 회원이 전화번호와 약관 동의 여부를 등록한다. */ @@ -28,4 +29,13 @@ public interface AppMemberApi { "PHONE_NUMBER_ALREADY_EXISTS" }) ApiResponse signUp(AppApiUser apiUser, MemberSignUpRequest request); + + /** FCM 토큰 등록. 앱이 Firebase에서 받은 등록 토큰을 로그인한 회원에게 저장한다. */ + @Operation(summary = "FCM 토큰 등록", + description = """ + 로그인한 회원의 FCM 등록 토큰을 저장한다. 로그인 직후와 토큰이 갱신될 때(onTokenRefresh) 호출한다. + 같은 토큰을 가진 다른 회원이 있으면 그 회원의 토큰은 지운다(한 기기에서 계정을 바꾼 경우).""") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"}) + ApiResponse updateFcmToken(AppApiUser apiUser, FcmTokenUpdateRequest request); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java index b40c3a98..67d0ae93 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java @@ -2,10 +2,12 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.member.request.FcmTokenUpdateRequest; import kr.ac.kookmin.stream.api.app.core.member.request.MemberSignUpRequest; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,4 +29,14 @@ public ApiResponse signUp( memberService.signUp(apiUser.userId(), request.toCommand()); return ApiResponse.success(); } + + @Override + @PatchMapping("/me/fcm-token") + public ApiResponse updateFcmToken( + AppApiUser apiUser, + @Valid @RequestBody FcmTokenUpdateRequest request + ) { + memberService.updateFcmToken(apiUser.userId(), request.fcmToken()); + return ApiResponse.success(); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/FcmTokenUpdateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/FcmTokenUpdateRequest.java new file mode 100644 index 00000000..c305a2fb --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/request/FcmTokenUpdateRequest.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.api.app.core.member.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record FcmTokenUpdateRequest( + // FCM 등록 토큰({인스턴스 ID}:APA91b...)과 FID 모두 base64url 문자와 ':'만 쓴다. 길이는 members.fcm_token 컬럼에 맞춘다 + @NotBlank(message = "FCM 토큰을 입력해 주세요.") + @Size(max = 255, message = "FCM 토큰은 255자 이하여야 합니다.") + @Pattern(regexp = "^[A-Za-z0-9_:-]+$", message = "FCM 토큰 형식이 올바르지 않습니다.") + String fcmToken +) {} From 251575949ce74ba195585332722ba82ad8462946 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:57:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EB=B3=80=EA=B2=BD=EB=90=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EB=A7=8C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java index 2ea39bfc..8a084ba7 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java @@ -15,8 +15,11 @@ import kr.ac.kookmin.stream.member.domain.member.domain.Member; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; @Entity +// 가입·FCM 토큰 갱신 등이 동시에 저장될 때 서로 바꾼 컬럼을 옛 값으로 덮어쓰지 않도록 바뀐 컬럼만 UPDATE한다 +@DynamicUpdate // uk_members_active_student_id(V1)·uk_members_active_phone_number(V16)는 generated column // active_student_id·active_phone_number에 걸려 있다. Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table(name = "members")