From 8f0ca1b68a28fd0b32a49b1f0efb7b6ff27dc96d Mon Sep 17 00:00:00 2001 From: xxeol2 Date: Tue, 26 Sep 2023 18:29:04 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[BE]=20refactor:=20Ticket=EC=9D=98=20scho?= =?UTF-8?q?ol=20not=20null=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#465)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Ticket의 school not null 제약조건 제거 * refactor: Ticket의 school not null 제약조건 제거 * refactor: Festival의 school not null 제약조건 제거 --- .../src/main/java/com/festago/festival/domain/Festival.java | 1 - backend/src/main/java/com/festago/ticket/domain/Ticket.java | 1 - backend/src/main/resources/db/migration/V4__add_school_fk.sql | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/festago/festival/domain/Festival.java b/backend/src/main/java/com/festago/festival/domain/Festival.java index 4d2a013d2..64602cc87 100644 --- a/backend/src/main/java/com/festago/festival/domain/Festival.java +++ b/backend/src/main/java/com/festago/festival/domain/Festival.java @@ -39,7 +39,6 @@ public class Festival extends BaseTimeEntity { @Size(max = 255) private String thumbnail; - @NotNull @ManyToOne(fetch = FetchType.LAZY) private School school; diff --git a/backend/src/main/java/com/festago/ticket/domain/Ticket.java b/backend/src/main/java/com/festago/ticket/domain/Ticket.java index 67f1604cf..fe873d0ff 100644 --- a/backend/src/main/java/com/festago/ticket/domain/Ticket.java +++ b/backend/src/main/java/com/festago/ticket/domain/Ticket.java @@ -39,7 +39,6 @@ public class Ticket extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) private Stage stage; - @NotNull private Long schoolId; @NotNull diff --git a/backend/src/main/resources/db/migration/V4__add_school_fk.sql b/backend/src/main/resources/db/migration/V4__add_school_fk.sql index 42a816f19..bcdb2f62a 100644 --- a/backend/src/main/resources/db/migration/V4__add_school_fk.sql +++ b/backend/src/main/resources/db/migration/V4__add_school_fk.sql @@ -1,5 +1,5 @@ alter table ticket - add column school_id bigint not null; + add column school_id bigint; alter table ticket add constraint fk_ticket__school @@ -7,7 +7,7 @@ alter table ticket references school (id); alter table festival - add column school_id bigint not null; + add column school_id bigint; alter table festival add constraint fk_festival__school From 0a1f23783a949c84fa77e871315fe3dbe02e16b3 Mon Sep 17 00:00:00 2001 From: Guga Date: Tue, 26 Sep 2023 19:13:49 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[BE]=20feat:=20=EC=8A=A4=ED=83=9C?= =?UTF-8?q?=ED=94=84=20=EC=9D=B8=EC=A6=9D=20=EC=B2=98=EB=A6=AC=EC=8B=9C=20?= =?UTF-8?q?FCM=20=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=95=B8=EB=93=9C=ED=8F=B0=EC=97=90=20=EC=9E=85=EC=9E=A5=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=99=84=EB=A3=8C=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EB=B3=B4=EB=82=B8=EB=8B=A4(#444)=20(#449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: FCMConfig 생성 * chore: FCMConfig 패키지 이동 * feat: MemberFCM 생성 * feat: MemberFCMService 생성 * feat: NotificationFacade 생성 * feat: 로그인 시 fcm 을 등록한다. * feat: 입장 처리 시 EntryProcessEvent 를 발행한다. * feat: 유저 탈퇴시 유저의 FCM 을 모두 삭제한다. * feat: 테스트 환경에서 FirebaseMessaging 을 MockBean 으로 등록한다 * chore: 테스트 컨벤션 적용 * chore: Notification -> FCMNotificationFacade 네이밍 변견 * feat: flyway 추가 * chore: 마지막 줄 개행 추가 * feat: submodule 업데이트 * refactor: 메서드 네이밍, 메서드 순서, 파라미터 순서 변경 * refactor: fcm bean 을 테스트에서 제외 * feat: EventListener phase 명시 * chore: FCMEventListener 네이밍 변경 * feat: MemberFCM 의 Member 의존성 제거 * chore: EntryProcessEvent 패키지 분리 * refactor: AuthService 가 MemberFCM 을 의존하지 않도록 변경 * feat: MemberFCM 빈 생성자 추가 * chore: flyway version 변경 * feat: FCMChannel 을 Enum 으로 관리 * chore: 메서드 접근자 및 네이밍 변경, log 메세지 변경 * feat: local prod, dev 환경에서만 FCM Bean 들이 생성되도록 변경 * refactor: eventListen 로직을 비동기적으로 처리한다 * refactor: LoginService 와 MemberFCMService 를 분리한다 * chore: 파라미터 네이밍 변경 * chore: logger -> log 네이밍 변경 * chore: log 메시지 변경 * chore: flyway 버전 변경 --- backend/build.gradle | 3 + .../auth/application/AuthFacadeService.java | 13 +-- .../festago/auth/application/AuthService.java | 19 ++-- .../com/festago/auth/dto/LoginRequest.java | 3 +- .../festago/common/exception/ErrorCode.java | 3 +- .../entry/application/EntryService.java | 8 +- .../entry/dto/event/EntryProcessEvent.java | 6 ++ .../FCMNotificationEventListener.java | 90 +++++++++++++++++++ .../fcm/application/MemberFCMService.java | 48 ++++++++++ .../com/festago/fcm/config/FCMConfig.java | 58 ++++++++++++ .../com/festago/fcm/domain/FCMChannel.java | 5 ++ .../com/festago/fcm/domain/MemberFCM.java | 56 ++++++++++++ .../festago/fcm/dto/MemberFCMResponse.java | 18 ++++ .../festago/fcm/dto/MemberFCMsResponse.java | 17 ++++ .../fcm/repository/MemberFCMRepository.java | 10 +++ .../festago/presentation/AuthController.java | 8 +- .../db/migration/V5__MembeFCM_Added.sql | 16 ++++ .../festago/application/EntryServiceTest.java | 8 ++ .../application/AuthFacadeServiceTest.java | 5 +- .../AuthFacadeServiceIntegrationTest.java | 6 +- .../auth/presentation/AuthControllerTest.java | 8 +- .../FCMNotificationEventListenerTest.java | 75 ++++++++++++++++ .../fcm/application/MemberFCMServiceTest.java | 72 +++++++++++++++ .../repository/MemberFCMRepositoryTest.java | 36 ++++++++ 24 files changed, 564 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/festago/entry/dto/event/EntryProcessEvent.java create mode 100644 backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java create mode 100644 backend/src/main/java/com/festago/fcm/application/MemberFCMService.java create mode 100644 backend/src/main/java/com/festago/fcm/config/FCMConfig.java create mode 100644 backend/src/main/java/com/festago/fcm/domain/FCMChannel.java create mode 100644 backend/src/main/java/com/festago/fcm/domain/MemberFCM.java create mode 100644 backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java create mode 100644 backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java create mode 100644 backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java create mode 100644 backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql create mode 100644 backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java create mode 100644 backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java create mode 100644 backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java diff --git a/backend/build.gradle b/backend/build.gradle index d36481f4e..54a71cbfe 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -47,6 +47,9 @@ dependencies { // Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + + // Firebase + implementation 'com.google.firebase:firebase-admin:8.1.0' } tasks.named('test') { diff --git a/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java b/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java index b87ffea85..3e8a22347 100644 --- a/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java +++ b/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java @@ -2,9 +2,9 @@ import com.festago.auth.domain.AuthPayload; import com.festago.auth.domain.Role; +import com.festago.auth.domain.SocialType; import com.festago.auth.domain.UserInfo; import com.festago.auth.dto.LoginMemberDto; -import com.festago.auth.dto.LoginRequest; import com.festago.auth.dto.LoginResponse; import org.springframework.stereotype.Service; @@ -22,8 +22,9 @@ public AuthFacadeService(AuthService authService, OAuth2Clients oAuth2Clients, this.authProvider = authProvider; } - public LoginResponse login(LoginRequest request) { - LoginMemberDto loginMember = authService.login(getUserInfo(request)); + public LoginResponse login(SocialType socialType, String oAuthToken) { + UserInfo userInfo = getUserInfo(socialType, oAuthToken); + LoginMemberDto loginMember = authService.login(userInfo); String accessToken = getAccessToken(loginMember.memberId()); return LoginResponse.of(accessToken, loginMember); } @@ -32,9 +33,9 @@ private String getAccessToken(Long memberId) { return authProvider.provide(new AuthPayload(memberId, Role.MEMBER)); } - private UserInfo getUserInfo(LoginRequest request) { - OAuth2Client oAuth2Client = oAuth2Clients.getClient(request.socialType()); - return oAuth2Client.getUserInfo(request.accessToken()); + private UserInfo getUserInfo(SocialType socialType, String oAuthToken) { + OAuth2Client oAuth2Client = oAuth2Clients.getClient(socialType); + return oAuth2Client.getUserInfo(oAuthToken); } public void deleteMember(Long memberId) { diff --git a/backend/src/main/java/com/festago/auth/application/AuthService.java b/backend/src/main/java/com/festago/auth/application/AuthService.java index f559d219b..63101e914 100644 --- a/backend/src/main/java/com/festago/auth/application/AuthService.java +++ b/backend/src/main/java/com/festago/auth/application/AuthService.java @@ -6,6 +6,7 @@ import com.festago.common.exception.NotFoundException; import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -24,12 +25,18 @@ public AuthService(MemberRepository memberRepository) { } public LoginMemberDto login(UserInfo userInfo) { - return memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType()) - .map(LoginMemberDto::isExists) - .orElseGet(() -> { - Member member = signUp(userInfo); - return LoginMemberDto.isNew(member); - }); + LoginMemberDto loginMemberDto = handleLoginRequest(userInfo); + return loginMemberDto; + } + + public LoginMemberDto handleLoginRequest(UserInfo userInfo) { + Optional originMember = memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType()); + if (originMember.isPresent()) { + Member member = originMember.get(); + return LoginMemberDto.isExists(member); + } + Member newMember = signUp(userInfo); + return LoginMemberDto.isNew(newMember); } private Member signUp(UserInfo userInfo) { diff --git a/backend/src/main/java/com/festago/auth/dto/LoginRequest.java b/backend/src/main/java/com/festago/auth/dto/LoginRequest.java index 8f82722aa..d05066b51 100644 --- a/backend/src/main/java/com/festago/auth/dto/LoginRequest.java +++ b/backend/src/main/java/com/festago/auth/dto/LoginRequest.java @@ -5,6 +5,7 @@ public record LoginRequest( @NotNull(message = "socialType 은 null 일 수 없습니다.") SocialType socialType, - @NotNull(message = "acessToken 은 null 일 수 없습니다.") String accessToken) { + @NotNull(message = "acessToken 은 null 일 수 없습니다.") String accessToken, + @NotNull(message = "fcmToken 은 null 일 수 없습니다.") String fcmToken) { } diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index e143257d8..bd3708054 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -58,7 +58,8 @@ public enum ErrorCode { INVALID_ENTRY_CODE_OFFSET("올바르지 않은 입장코드 오프셋입니다."), INVALID_ROLE_NAME("해당하는 Role이 없습니다."), FOR_TEST_ERROR("테스트용 에러입니다."), - ; + FAIL_SEND_FCM_MESSAGE("FCM Message 전송에 실패했습니다."), + FCM_NOT_FOUND("유효하지 않은 MemberFCM 이 감지 되었습니다."); private final String message; diff --git a/backend/src/main/java/com/festago/entry/application/EntryService.java b/backend/src/main/java/com/festago/entry/application/EntryService.java index 1e2f23ec3..68602f67e 100644 --- a/backend/src/main/java/com/festago/entry/application/EntryService.java +++ b/backend/src/main/java/com/festago/entry/application/EntryService.java @@ -8,10 +8,12 @@ import com.festago.entry.dto.EntryCodeResponse; import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; +import com.festago.entry.dto.event.EntryProcessEvent; import com.festago.ticketing.domain.MemberTicket; import com.festago.ticketing.repository.MemberTicketRepository; import java.time.Clock; import java.time.LocalDateTime; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,11 +23,14 @@ public class EntryService { private final EntryCodeManager entryCodeManager; private final MemberTicketRepository memberTicketRepository; + private final ApplicationEventPublisher publisher; private final Clock clock; - public EntryService(EntryCodeManager entryCodeManager, MemberTicketRepository memberTicketRepository, Clock clock) { + public EntryService(EntryCodeManager entryCodeManager, MemberTicketRepository memberTicketRepository, + ApplicationEventPublisher publisher, Clock clock) { this.entryCodeManager = entryCodeManager; this.memberTicketRepository = memberTicketRepository; + this.publisher = publisher; this.clock = clock; } @@ -50,6 +55,7 @@ public TicketValidationResponse validate(TicketValidationRequest request) { EntryCodePayload entryCodePayload = entryCodeManager.extract(request.code()); MemberTicket memberTicket = findMemberTicket(entryCodePayload.getMemberTicketId()); memberTicket.changeState(entryCodePayload.getEntryState()); + publisher.publishEvent(new EntryProcessEvent(memberTicket.getOwner().getId())); return TicketValidationResponse.from(memberTicket); } } diff --git a/backend/src/main/java/com/festago/entry/dto/event/EntryProcessEvent.java b/backend/src/main/java/com/festago/entry/dto/event/EntryProcessEvent.java new file mode 100644 index 000000000..d94ac71d9 --- /dev/null +++ b/backend/src/main/java/com/festago/entry/dto/event/EntryProcessEvent.java @@ -0,0 +1,6 @@ +package com.festago.entry.dto.event; + +public record EntryProcessEvent( + Long memberId) { + +} diff --git a/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java new file mode 100644 index 000000000..1de6c5418 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java @@ -0,0 +1,90 @@ +package com.festago.fcm.application; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.entry.dto.event.EntryProcessEvent; +import com.festago.fcm.domain.FCMChannel; +import com.festago.fcm.dto.MemberFCMResponse; +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.SendResponse; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@Profile({"dev", "prod"}) +public class FCMNotificationEventListener { + + private static final Logger log = LoggerFactory.getLogger(FCMNotificationEventListener.class); + + private final FirebaseMessaging firebaseMessaging; + private final MemberFCMService memberFCMService; + + public FCMNotificationEventListener(FirebaseMessaging firebaseMessaging, MemberFCMService memberFCMService) { + this.firebaseMessaging = firebaseMessaging; + this.memberFCMService = memberFCMService; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void sendFcmNotification(EntryProcessEvent event) { + List messages = createMessages(getMemberFCMToken(event.memberId()), FCMChannel.NOT_DEFINED.name()); + try { + BatchResponse batchResponse = firebaseMessaging.sendAll(messages); + checkAllSuccess(batchResponse, event.memberId()); + } catch (FirebaseMessagingException e) { + log.error("fail send FCM message", e); + throw new InternalServerException(ErrorCode.FAIL_SEND_FCM_MESSAGE); + } + } + + private List getMemberFCMToken(Long memberId) { + return memberFCMService.findMemberFCM(memberId).memberFCMs().stream() + .map(MemberFCMResponse::fcmToken) + .toList(); + } + + private List createMessages(List tokens, String channelId) { + return tokens.stream() + .map(token -> createMessage(token, channelId)) + .toList(); + } + + private Message createMessage(String token, String channelId) { + return Message.builder() + .setAndroidConfig(createAndroidConfig(channelId)) + .setToken(token) + .build(); + } + + private AndroidConfig createAndroidConfig(String channelId) { + return AndroidConfig.builder() + .setNotification(createAndroidNotification(channelId)) + .build(); + } + + private AndroidNotification createAndroidNotification(String channelId) { + return AndroidNotification.builder() + .setChannelId(channelId) + .build(); + } + + private void checkAllSuccess(BatchResponse batchResponse, Long memberId) { + List failSend = batchResponse.getResponses().stream() + .filter(sendResponse -> !sendResponse.isSuccessful()) + .toList(); + + log.warn("member {} 에 대한 다음 요청들이 실패했습니다. {}", memberId, failSend); + throw new InternalServerException(ErrorCode.FAIL_SEND_FCM_MESSAGE); + } +} diff --git a/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java new file mode 100644 index 000000000..e044feb7a --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java @@ -0,0 +1,48 @@ +package com.festago.fcm.application; + +import com.festago.auth.application.AuthExtractor; +import com.festago.auth.domain.AuthPayload; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.fcm.domain.MemberFCM; +import com.festago.fcm.dto.MemberFCMsResponse; +import com.festago.fcm.repository.MemberFCMRepository; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class MemberFCMService { + + private static final Logger log = LoggerFactory.getLogger(MemberFCMService.class); + private static final int LEAST_MEMBER_FCM = 1; + + private final MemberFCMRepository memberFCMRepository; + private final AuthExtractor authExtractor; + + public MemberFCMService(MemberFCMRepository memberFCMRepository, AuthExtractor authExtractor) { + this.memberFCMRepository = memberFCMRepository; + this.authExtractor = authExtractor; + } + + @Transactional(readOnly = true) + public MemberFCMsResponse findMemberFCM(Long memberId) { + List memberFCM = memberFCMRepository.findByMemberId(memberId); + if (memberFCM.size() < LEAST_MEMBER_FCM) { + log.error("member {} 의 FCM 토큰이 발급되지 않았습니다.", memberId); + throw new InternalServerException(ErrorCode.FCM_NOT_FOUND); + } + return MemberFCMsResponse.from(memberFCM); + } + + @Async + public void saveMemberFCM(String accessToken, String fcmToken) { + AuthPayload authPayload = authExtractor.extract(accessToken); + Long memberId = authPayload.getMemberId(); + memberFCMRepository.save(new MemberFCM(memberId, fcmToken)); + } +} diff --git a/backend/src/main/java/com/festago/fcm/config/FCMConfig.java b/backend/src/main/java/com/festago/fcm/config/FCMConfig.java new file mode 100644 index 000000000..93b3dfab2 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/config/FCMConfig.java @@ -0,0 +1,58 @@ +package com.festago.fcm.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; + +@Configuration +@Profile({"prod", "dev"}) +public class FCMConfig { + + @Value("${fcm.key.path}") + private String fcmPrivateKeyPath; + + @Value("${fcm.key.scope}") + private String fireBaseScope; + + @Bean + public FirebaseMessaging firebaseMessaging() throws IOException { + Optional defaultFirebaseApp = defaultFirebaseApp(); + if (defaultFirebaseApp.isPresent()) { + return FirebaseMessaging.getInstance(defaultFirebaseApp.get()); + } + return FirebaseMessaging.getInstance( + FirebaseApp.initializeApp(createFirebaseOption()) + ); + } + + private Optional defaultFirebaseApp() { + List firebaseAppList = FirebaseApp.getApps(); + if (firebaseAppList == null || firebaseAppList.isEmpty()) { + return Optional.empty(); + } + return firebaseAppList.stream() + .filter(firebaseApp -> firebaseApp.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) + .findAny(); + } + + private FirebaseOptions createFirebaseOption() throws IOException { + return FirebaseOptions.builder() + .setCredentials(createGoogleCredentials()) + .build(); + } + + private GoogleCredentials createGoogleCredentials() throws IOException { + return GoogleCredentials + .fromStream(new ClassPathResource(fcmPrivateKeyPath).getInputStream()) + .createScoped(fireBaseScope); + } +} diff --git a/backend/src/main/java/com/festago/fcm/domain/FCMChannel.java b/backend/src/main/java/com/festago/fcm/domain/FCMChannel.java new file mode 100644 index 000000000..836622edf --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/domain/FCMChannel.java @@ -0,0 +1,5 @@ +package com.festago.fcm.domain; + +public enum FCMChannel { + NOT_DEFINED; +} diff --git a/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java new file mode 100644 index 000000000..8a97f7f07 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java @@ -0,0 +1,56 @@ +package com.festago.fcm.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +@Entity +@Table(name = "member_fcm") +public class MemberFCM extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long memberId; + + @NotNull + private String fcmToken; + + protected MemberFCM() { + } + + public MemberFCM(Long memberId, String fcmToken) { + this(null, memberId, fcmToken); + } + + public MemberFCM(Long id, Long memberId, String fcmToken) { + validate(memberId, fcmToken); + this.id = id; + this.memberId = memberId; + this.fcmToken = fcmToken; + } + + private void validate(Long memberId, String fcmToken) { + if (memberId == null || fcmToken == null) { + throw new IllegalArgumentException("MemberFCM 은 허용되지 않은 null 값으로 생성할 수 없습니다."); + } + } + + public Long getId() { + return id; + } + + public Long getMemberId() { + return memberId; + } + + public String getFcmToken() { + return fcmToken; + } +} diff --git a/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java b/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java new file mode 100644 index 000000000..61f252a9b --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java @@ -0,0 +1,18 @@ +package com.festago.fcm.dto; + +import com.festago.fcm.domain.MemberFCM; + +public record MemberFCMResponse( + Long id, + Long memberId, + String fcmToken +) { + + public static MemberFCMResponse from(MemberFCM memberFCM) { + return new MemberFCMResponse( + memberFCM.getId(), + memberFCM.getMemberId(), + memberFCM.getFcmToken() + ); + } +} diff --git a/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java b/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java new file mode 100644 index 000000000..80df8a602 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java @@ -0,0 +1,17 @@ +package com.festago.fcm.dto; + +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toList; + +import com.festago.fcm.domain.MemberFCM; +import java.util.List; + +public record MemberFCMsResponse(List memberFCMs +) { + + public static MemberFCMsResponse from(List memberFCMs) { + return memberFCMs.stream() + .map(MemberFCMResponse::from) + .collect(collectingAndThen(toList(), MemberFCMsResponse::new)); + } +} diff --git a/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java new file mode 100644 index 000000000..e5bee3719 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java @@ -0,0 +1,10 @@ +package com.festago.fcm.repository; + +import com.festago.fcm.domain.MemberFCM; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberFCMRepository extends JpaRepository { + + List findByMemberId(Long memberId); +} diff --git a/backend/src/main/java/com/festago/presentation/AuthController.java b/backend/src/main/java/com/festago/presentation/AuthController.java index 9d0e83f6f..36038cdbf 100644 --- a/backend/src/main/java/com/festago/presentation/AuthController.java +++ b/backend/src/main/java/com/festago/presentation/AuthController.java @@ -4,6 +4,7 @@ import com.festago.auth.application.AuthFacadeService; import com.festago.auth.dto.LoginRequest; import com.festago.auth.dto.LoginResponse; +import com.festago.fcm.application.MemberFCMService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,15 +21,18 @@ public class AuthController { private final AuthFacadeService authFacadeService; + private final MemberFCMService memberFCMService; - public AuthController(AuthFacadeService authFacadeService) { + public AuthController(AuthFacadeService authFacadeService, MemberFCMService memberFCMService) { this.authFacadeService = authFacadeService; + this.memberFCMService = memberFCMService; } @PostMapping("/oauth2") @Operation(description = "소셜 엑세스 토큰을 기반으로 로그인 요청을 보낸다.", summary = "OAuth2 로그인") public ResponseEntity login(@RequestBody LoginRequest request) { - LoginResponse response = authFacadeService.login(request); + LoginResponse response = authFacadeService.login(request.socialType(), request.accessToken()); + memberFCMService.saveMemberFCM(response.accessToken(), request.fcmToken()); return ResponseEntity.ok() .body(response); } diff --git a/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql b/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql new file mode 100644 index 000000000..f71564c9d --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql @@ -0,0 +1,16 @@ +create table if not exists member_fcm +( + id bigint not null auto_increment, + created_at datetime(6), + updated_at datetime(6), + member_id bigint, + fcm_token varchar(255), + primary key (id) +) engine innodb + default charset = utf8mb4 + collate = utf8mb4_0900_ai_ci; + +alter table member_fcm + add constraint fk_member_fcm__member + foreign key (member_id) + references member (id); diff --git a/backend/src/test/java/com/festago/application/EntryServiceTest.java b/backend/src/test/java/com/festago/application/EntryServiceTest.java index 962c1a3af..4da22514a 100644 --- a/backend/src/test/java/com/festago/application/EntryServiceTest.java +++ b/backend/src/test/java/com/festago/application/EntryServiceTest.java @@ -6,6 +6,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.anyLong; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.NotFoundException; @@ -16,6 +18,7 @@ import com.festago.entry.dto.EntryCodeResponse; import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; +import com.festago.entry.dto.event.EntryProcessEvent; import com.festago.festival.domain.Festival; import com.festago.member.domain.Member; import com.festago.stage.domain.Stage; @@ -42,6 +45,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) @DisplayNameGeneration(ReplaceUnderscores.class) @@ -54,6 +58,9 @@ class EntryServiceTest { @Mock MemberTicketRepository memberTicketRepository; + @Mock + ApplicationEventPublisher publisher; + @Spy Clock clock = Clock.systemDefaultZone(); @@ -246,6 +253,7 @@ class 티켓_검사 { softly.assertThat(memberTicket.getEntryState()).isEqualTo(EntryState.BEFORE_ENTRY); softly.assertThat(expect.updatedState()).isEqualTo(EntryState.BEFORE_ENTRY); }); + verify(publisher, times(1)).publishEvent(any(EntryProcessEvent.class)); } } } diff --git a/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java b/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java index e953d8e06..f2f51ef1a 100644 --- a/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java +++ b/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java @@ -9,7 +9,6 @@ import com.festago.auth.domain.SocialType; import com.festago.auth.domain.UserInfo; import com.festago.auth.dto.LoginMemberDto; -import com.festago.auth.dto.LoginRequest; import com.festago.auth.dto.LoginResponse; import com.festago.auth.infrastructure.FestagoOAuth2Client; import com.festago.member.domain.Member; @@ -45,8 +44,6 @@ void setUp() { @Test void 로그인() { - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "1"); - Member member = MemberFixture.member() .id(1L) .build(); @@ -58,7 +55,7 @@ void setUp() { .willReturn(new LoginMemberDto(false, member.getId(), member.getNickname())); // when - LoginResponse response = authFacadeService.login(request); + LoginResponse response = authFacadeService.login(SocialType.FESTAGO, "1"); // then assertThat(response) diff --git a/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java b/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java index 0724545a0..4024fe487 100644 --- a/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java @@ -6,7 +6,6 @@ import com.festago.application.integration.ApplicationIntegrationTest; import com.festago.auth.application.AuthFacadeService; import com.festago.auth.domain.SocialType; -import com.festago.auth.dto.LoginRequest; import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; import com.festago.support.MemberFixture; @@ -33,13 +32,12 @@ class AuthFacadeServiceIntegrationTest extends ApplicationIntegrationTest { @Test void 회원이_탈퇴하고_재가입하면_새로운_계정으로_가입() { // given - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "1"); - authFacadeService.login(request); + authFacadeService.login(SocialType.FESTAGO, "1"); Member member = memberRepository.findBySocialIdAndSocialType("1", SocialType.FESTAGO).get(); // when memberRepository.delete(member); - authFacadeService.login(request); + authFacadeService.login(SocialType.FESTAGO, "1"); // then assertThat(memberRepository.count()).isEqualTo(1); diff --git a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java index 281261a03..19c22eb68 100644 --- a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java +++ b/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java @@ -14,6 +14,7 @@ import com.festago.auth.domain.SocialType; import com.festago.auth.dto.LoginRequest; import com.festago.auth.dto.LoginResponse; +import com.festago.fcm.application.MemberFCMService; import com.festago.presentation.AuthController; import com.festago.support.CustomWebMvcTest; import com.festago.support.WithMockAuth; @@ -39,13 +40,16 @@ class AuthControllerTest { @MockBean AuthFacadeService authFacadeService; + @MockBean + MemberFCMService memberFCMService; + @Test void OAuth2_로그인을_한다() throws Exception { // given LoginResponse expected = new LoginResponse("accesstoken", "nickname", true); - given(authFacadeService.login(any(LoginRequest.class))) + given(authFacadeService.login(any(), any())) .willReturn(expected); - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code"); + LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", "fcmToken"); // when & then String response = mockMvc.perform(post("/auth/oauth2") diff --git a/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java new file mode 100644 index 000000000..795aa245c --- /dev/null +++ b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java @@ -0,0 +1,75 @@ +package com.festago.fcm.application; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.festago.common.exception.InternalServerException; +import com.festago.entry.dto.event.EntryProcessEvent; +import com.festago.fcm.dto.MemberFCMResponse; +import com.festago.fcm.dto.MemberFCMsResponse; +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.SendResponse; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class FCMNotificationEventListenerTest { + + @Mock + FirebaseMessaging firebaseMessaging; + + @Mock + MemberFCMService memberFCMService; + + @InjectMocks + FCMNotificationEventListener FCMNotificationEventListener; + + @Test + void 유저의_FCM_요청_중_하나라도_실패하면_예외() throws FirebaseMessagingException { + // given + given(memberFCMService.findMemberFCM(anyLong())).willReturn( + new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), new MemberFCMResponse(2L, 1L, "token2")))); + + given(firebaseMessaging.sendAll(any())).willReturn(new MockBatchResponse()); + + EntryProcessEvent event = new EntryProcessEvent(1L); + + // when & then + Assertions.assertThatThrownBy(() -> FCMNotificationEventListener.sendFcmNotification(event)) + .isInstanceOf(InternalServerException.class); + } + + private static class MockBatchResponse implements BatchResponse { + + @Override + public List getResponses() { + SendResponse mockResponse = mock(SendResponse.class); + when(mockResponse.isSuccessful()).thenReturn(false); + return List.of(mockResponse, mockResponse); + } + + @Override + public int getSuccessCount() { + return 0; + } + + @Override + public int getFailureCount() { + return 0; + } + } +} diff --git a/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java new file mode 100644 index 000000000..dd8eb7d46 --- /dev/null +++ b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java @@ -0,0 +1,72 @@ +package com.festago.fcm.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.festago.auth.application.AuthExtractor; +import com.festago.auth.domain.AuthPayload; +import com.festago.auth.domain.Role; +import com.festago.fcm.domain.MemberFCM; +import com.festago.fcm.dto.MemberFCMResponse; +import com.festago.fcm.repository.MemberFCMRepository; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MemberFCMServiceTest { + + @Mock + MemberFCMRepository memberFCMRepository; + + @Mock + AuthExtractor authExtractor; + + @InjectMocks + MemberFCMService memberFCMService; + + @Test + void 유저의_FCM_정보를_가져온다() { + // given + List memberFCMS = List.of( + new MemberFCM(1L, 1L, "token"), + new MemberFCM(2L, 1L, "token2") + ); + given(memberFCMRepository.findByMemberId(anyLong())) + .willReturn(memberFCMS); + + List expect = memberFCMS.stream() + .map(MemberFCMResponse::from) + .collect(Collectors.toList()); + + // when + List actual = memberFCMService.findMemberFCM(1L).memberFCMs(); + + // then + assertThat(actual).isEqualTo(expect); + } + + @Test + void 유저의_FCM_저장() { + // given + String accessToken = "accessToken"; + String fcmToken = "fcmToken"; + given(authExtractor.extract(any())) + .willReturn(new AuthPayload(1L, Role.MEMBER)); + + // when + memberFCMService.saveMemberFCM(accessToken, fcmToken); + + // then + verify(memberFCMRepository, times(1)) + .save(any(MemberFCM.class)); + } +} diff --git a/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java b/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java new file mode 100644 index 000000000..3ea135724 --- /dev/null +++ b/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java @@ -0,0 +1,36 @@ +package com.festago.fcm.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.auth.domain.SocialType; +import com.festago.fcm.domain.MemberFCM; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest +class MemberFCMRepositoryTest { + + @Autowired + MemberFCMRepository memberFCMRepository; + + @Autowired + MemberRepository memberRepository; + + @Test + void member_의_MemberFCM_을_찾을_수_있다() { + // given + Member member = memberRepository.save(new Member("socialId", SocialType.FESTAGO, "nickname", "image.jpg")); + Long memberId = member.getId(); + MemberFCM expect = memberFCMRepository.save(new MemberFCM(memberId, "fcmToken")); + + // when + List actual = memberFCMRepository.findByMemberId(memberId); + + // then + assertThat(actual).contains(expect); + } +} From b0f8788358b328ca584fb4436e3c0c2db64d8d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=B4=EC=8B=9C?= <67777523+EmilyCh0@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:22:03 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[AN/USER]=20feat:=20=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84(#3?= =?UTF-8?q?74)=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 학교 선택 화면 구현 * feat: 학교 조회 api 연결 * feat: 학교 선택 화면 이동 * refactor: 뷰모델 init 대신 액티비티에서 호출하도록 수정 * test: 학교 선택 화면 테스트 작성 * refactor: SchoolUiState 제거 * feat: 로딩, 에러 화면 처리 * refactor: 불필요한 프로퍼티 제거 * feat: 학교 선택 상단 제목 추가 * test: analyticsHelper relaxed true 설정 * refactor: 파라미터 순서 변경 * fix: 화면 회전 시 schoolSelected 값 유지되도록 수정 --- .../festago/app/src/main/AndroidManifest.xml | 5 +- .../festago/data/di/ViewModelScopeModule.kt | 8 +- .../di/singletonscope/RepositoryModule.kt | 6 - .../data/di/singletonscope/ServiceModule.kt | 9 ++ .../festago/data/dto/SchoolResponse.kt | 17 +++ .../festago/data/dto/SchoolsResponse.kt | 11 ++ .../repository/SchoolDefaultRepository.kt | 14 +- .../data/service/SchoolRetrofitService.kt | 11 ++ .../ui/home/mypage/MyPageFragment.kt | 6 +- .../ui/selectschool/SelectSchoolActivity.kt | 87 ++++++++++++ .../ui/selectschool/SelectSchoolEvent.kt | 5 + .../ui/selectschool/SelectSchoolUiState.kt | 21 +++ .../ui/selectschool/SelectSchoolViewModel.kt | 71 ++++++++++ .../res/layout/activity_select_school.xml | 96 +++++++++++++ .../main/res/layout/item_select_school.xml | 7 + .../app/src/main/res/values/strings.xml | 6 + .../selectschool/SelectSchoolViewModelTest.kt | 134 ++++++++++++++++++ .../java/com/festago/festago/model/School.kt | 7 + .../festago/repository/SchoolRepository.kt | 3 + 19 files changed, 512 insertions(+), 12 deletions(-) create mode 100644 android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt create mode 100644 android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt create mode 100644 android/festago/app/src/main/res/layout/activity_select_school.xml create mode 100644 android/festago/app/src/main/res/layout/item_select_school.xml create mode 100644 android/festago/app/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt create mode 100644 android/festago/domain/src/main/java/com/festago/festago/model/School.kt diff --git a/android/festago/app/src/main/AndroidManifest.xml b/android/festago/app/src/main/AndroidManifest.xml index d4f74b841..6d80b0e80 100644 --- a/android/festago/app/src/main/AndroidManifest.xml +++ b/android/festago/app/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ android:theme="@style/Theme.Festago" android:usesCleartextTraffic="true" tools:targetApi="31"> + @@ -61,4 +64,4 @@ android:exported="false" /> - \ No newline at end of file + diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt b/android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt index 403a4f89f..abe7d2efe 100644 --- a/android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt +++ b/android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt @@ -1,9 +1,11 @@ package com.festago.festago.data.di import com.festago.festago.data.repository.ReservationTicketDefaultRepository +import com.festago.festago.data.repository.SchoolDefaultRepository import com.festago.festago.data.repository.StudentVerificationDefaultRepository import com.festago.festago.data.repository.UserDefaultRepository import com.festago.festago.repository.ReservationTicketRepository +import com.festago.festago.repository.SchoolRepository import com.festago.festago.repository.StudentVerificationRepository import com.festago.festago.repository.UserRepository import dagger.Binds @@ -26,5 +28,9 @@ interface ViewModelScopeModule { @Binds @ViewModelScoped - fun binsUserDefaultRepository(userRepository: UserDefaultRepository): UserRepository + fun bindsUserDefaultRepository(userRepository: UserDefaultRepository): UserRepository + + @Binds + @ViewModelScoped + fun bindsSelectSchoolRepository(schoolRepository: SchoolDefaultRepository): SchoolRepository } diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt index fe922bbc4..1229d7c15 100644 --- a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt +++ b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt @@ -2,12 +2,10 @@ package com.festago.festago.data.di.singletonscope import com.festago.festago.data.repository.AuthDefaultRepository import com.festago.festago.data.repository.FestivalDefaultRepository -import com.festago.festago.data.repository.SchoolDefaultRepository import com.festago.festago.data.repository.TicketDefaultRepository import com.festago.festago.data.repository.TokenDefaultRepository import com.festago.festago.repository.AuthRepository import com.festago.festago.repository.FestivalRepository -import com.festago.festago.repository.SchoolRepository import com.festago.festago.repository.TicketRepository import com.festago.festago.repository.TokenRepository import dagger.Binds @@ -30,10 +28,6 @@ interface RepositoryModule { @Singleton fun bindsFestivalDefaultRepository(festivalRepository: FestivalDefaultRepository): FestivalRepository - @Binds - @Singleton - fun bindsSchoolDefaultRepository(schoolRepository: SchoolDefaultRepository): SchoolRepository - @Binds @Singleton fun bindsTicketDefaultRepository(ticketRepository: TicketDefaultRepository): TicketRepository diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt index a1154dc05..3cbb12e5e 100644 --- a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt +++ b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt @@ -2,6 +2,7 @@ package com.festago.festago.data.di.singletonscope import com.festago.festago.data.service.FestivalRetrofitService import com.festago.festago.data.service.ReservationTicketRetrofitService +import com.festago.festago.data.service.SchoolRetrofitService import com.festago.festago.data.service.StudentVerificationRetrofitService import com.festago.festago.data.service.TicketRetrofitService import com.festago.festago.data.service.TokenRetrofitService @@ -64,4 +65,12 @@ object ServiceModule { ): StudentVerificationRetrofitService { return retrofit.create(StudentVerificationRetrofitService::class.java) } + + @Provides + @Singleton + fun providesSchoolRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit + ): SchoolRetrofitService { + return retrofit.create(SchoolRetrofitService::class.java) + } } diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt b/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt new file mode 100644 index 000000000..52646b6d5 --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto + +import com.festago.festago.model.School +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolResponse( + val id: Int, + val domain: String, + val name: String +) { + fun toDomain(): School = School( + id = id.toLong(), + domain = domain, + name = name + ) +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt b/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt new file mode 100644 index 000000000..72e2d61ec --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.dto + +import com.festago.festago.model.School +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolsResponse( + val schools: List +) { + fun toDomain(): List = schools.map { it.toDomain() } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt b/android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt index 86d2ac26b..c0b5f93f3 100644 --- a/android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt +++ b/android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt @@ -1,9 +1,21 @@ package com.festago.festago.data.repository +import com.festago.festago.data.service.SchoolRetrofitService +import com.festago.festago.data.util.runCatchingWithErrorHandler +import com.festago.festago.model.School import com.festago.festago.repository.SchoolRepository import javax.inject.Inject -class SchoolDefaultRepository @Inject constructor() : SchoolRepository { +class SchoolDefaultRepository @Inject constructor( + private val schoolRetrofitService: SchoolRetrofitService +) : SchoolRepository { + + override suspend fun loadSchools(): Result> { + schoolRetrofitService.getSchools() + .runCatchingWithErrorHandler() + .getOrElse { error -> return Result.failure(error) } + .let { return Result.success(it.toDomain()) } + } override suspend fun loadSchoolEmail(schoolId: Long): Result { // TODO: API 연동 작업 필요 diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt b/android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt new file mode 100644 index 000000000..b21234535 --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.SchoolsResponse +import retrofit2.Response +import retrofit2.http.GET + +interface SchoolRetrofitService { + + @GET("/schools") + suspend fun getSchools(): Response +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt index 478d0fdd1..13d8f1afe 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt +++ b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt @@ -10,8 +10,8 @@ import androidx.fragment.app.viewModels import com.festago.festago.R import com.festago.festago.databinding.FragmentMyPageBinding import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.ui.selectschool.SelectSchoolActivity import com.festago.festago.presentation.ui.signin.SignInActivity -import com.festago.festago.presentation.ui.studentverification.StudentVerificationActivity import com.festago.festago.presentation.ui.tickethistory.TicketHistoryActivity import dagger.hilt.android.AndroidEntryPoint @@ -104,9 +104,9 @@ class MyPageFragment : Fragment(R.layout.fragment_my_page) { binding.srlMyPage.setOnRefreshListener { vm.loadUserInfo() } - // TODO: 학교 선택 화면 변경 필요 + binding.tvSchoolAuthorization.setOnClickListener { - startActivity(StudentVerificationActivity.getIntent(requireContext(), 1L)) + startActivity(SelectSchoolActivity.getIntent(requireContext())) } } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt new file mode 100644 index 000000000..46c0c76a0 --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt @@ -0,0 +1,87 @@ +package com.festago.festago.presentation.ui.selectschool + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.ArrayAdapter +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.festago.festago.R +import com.festago.festago.databinding.ActivitySelectSchoolBinding +import com.festago.festago.presentation.ui.studentverification.StudentVerificationActivity +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SelectSchoolActivity : AppCompatActivity() { + + private val binding: ActivitySelectSchoolBinding by lazy { + ActivitySelectSchoolBinding.inflate(layoutInflater) + } + + private val vm: SelectSchoolViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + initObserve() + initView() + } + + private fun initBinding() { + setContentView(binding.root) + binding.lifecycleOwner = this + binding.vm = vm + } + + private fun initObserve() { + repeatOnStarted { + vm.uiState.collect { uiState -> + handleUiState(uiState) + } + } + repeatOnStarted { + vm.event.collect { event -> + handleEvent(event) + } + } + } + + private fun initView() { + vm.loadSchools() + } + + private fun handleUiState(uiState: SelectSchoolUiState) { + binding.uiState = uiState + when (uiState) { + is SelectSchoolUiState.Loading, is SelectSchoolUiState.Error -> Unit + is SelectSchoolUiState.Success -> handleSuccess(uiState) + } + } + + private fun handleSuccess(uiState: SelectSchoolUiState.Success) { + val adapter = + ArrayAdapter(this, R.layout.item_select_school, uiState.schools.map { it.name }) + binding.actvSelectSchool.setAdapter(adapter) + binding.actvSelectSchool.setOnItemClickListener { _, _, position, _ -> + val selectedSchool = uiState.schools.firstOrNull { + it.name == adapter.getItem(position) + } + selectedSchool?.let { vm.selectSchool(it.id) } + } + } + + private fun handleEvent(event: SelectSchoolEvent) { + when (event) { + is SelectSchoolEvent.ShowStudentVerification -> { + startActivity(StudentVerificationActivity.getIntent(this, event.schoolId)) + } + } + } + + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, SelectSchoolActivity::class.java) + } + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt new file mode 100644 index 000000000..3464fe3e4 --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt @@ -0,0 +1,5 @@ +package com.festago.festago.presentation.ui.selectschool + +interface SelectSchoolEvent { + class ShowStudentVerification(val schoolId: Long) : SelectSchoolEvent +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt new file mode 100644 index 000000000..e71f6c70b --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt @@ -0,0 +1,21 @@ +package com.festago.festago.presentation.ui.selectschool + +import com.festago.festago.model.School + +interface SelectSchoolUiState { + object Loading : SelectSchoolUiState + + data class Success( + val schools: List, + val selectedSchoolId: Long? = null + ) : SelectSchoolUiState { + val schoolSelected = selectedSchoolId != null + } + + object Error : SelectSchoolUiState + + val enableNext get() = (this is Success) && schoolSelected + val shouldShowSuccess get() = this is Success + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt new file mode 100644 index 000000000..e49b07764 --- /dev/null +++ b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt @@ -0,0 +1,71 @@ +package com.festago.festago.presentation.ui.selectschool + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.repository.SchoolRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SelectSchoolViewModel @Inject constructor( + private val schoolRepository: SchoolRepository, + private val analyticsHelper: AnalyticsHelper +) : ViewModel() { + + private val _uiState = MutableStateFlow(SelectSchoolUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadSchools() { + viewModelScope.launch { + schoolRepository.loadSchools() + .onSuccess { schools -> + if (uiState.value is SelectSchoolUiState.Success) { + val successState = (uiState.value as SelectSchoolUiState.Success) + _uiState.value = successState.copy(schools = schools) + } else { + _uiState.value = SelectSchoolUiState.Success(schools) + } + } + .onFailure { + _uiState.value = SelectSchoolUiState.Error + analyticsHelper.logNetworkFailure(KEY_LOAD_SCHOOLS_LOG, it.message.toString()) + } + } + } + + fun selectSchool(schoolId: Long) { + if (uiState.value is SelectSchoolUiState.Success) { + _uiState.value = + (uiState.value as SelectSchoolUiState.Success).copy(selectedSchoolId = schoolId) + } + } + + fun showStudentVerification() { + viewModelScope.launch { + if (uiState.value is SelectSchoolUiState.Success) { + val success = uiState.value as SelectSchoolUiState.Success + success.selectedSchoolId?.let { schoolId -> + _event.emit( + SelectSchoolEvent.ShowStudentVerification(schoolId) + ) + } + } + } + } + + companion object { + private const val KEY_LOAD_SCHOOLS_LOG = "load_schools" + } +} diff --git a/android/festago/app/src/main/res/layout/activity_select_school.xml b/android/festago/app/src/main/res/layout/activity_select_school.xml new file mode 100644 index 000000000..41739185b --- /dev/null +++ b/android/festago/app/src/main/res/layout/activity_select_school.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +