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 9fd73ee..a13b436 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 b40c3a9..67d0ae9 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 0000000..c305a2f --- /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 +) {} 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 b273e03..158bc59 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 d2e74f8..bd0465e 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 0c584bc..39d1a0f 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 a8ad7c3..f6bdf2d 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 a1b224b..0fd828a 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 9996c87..0dba47a 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); + } }