diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index d7e095f..a282a66 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -37,7 +37,14 @@ jobs: echo "${{ secrets.PROPERTIES }}" >> ./application.properties shell: bash - + # 환경 변수 설정 (Firebase) + - name: create-json + id: create-json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_JSON_FILENAME }} + json: ${{ secrets.FIREBASE_JSON }} + dir: 'src/main/resources/firebase/' - name: Build with Gradle run: | diff --git a/.gitignore b/.gitignore index ecebe87..bb67412 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ out/ ### classpath ### application.properties -p8_key.txt \ No newline at end of file +p8_key.txt +firebase \ No newline at end of file diff --git a/build.gradle b/build.gradle index 242550e..6790343 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + implementation 'com.google.firebase:firebase-admin:9.1.1' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } // Querydsl 설정부 diff --git a/src/main/java/igoMoney/BE/common/config/FCMConfig.java b/src/main/java/igoMoney/BE/common/config/FCMConfig.java new file mode 100644 index 0000000..4a1ee3c --- /dev/null +++ b/src/main/java/igoMoney/BE/common/config/FCMConfig.java @@ -0,0 +1,42 @@ +package igoMoney.BE.common.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Configuration +public class FCMConfig { + + @Value("${firebase-json}") + private String firebaseJsonFileName; + + @Bean + FirebaseMessaging init() throws IOException { + ClassPathResource resource = new ClassPathResource("firebase/"+firebaseJsonFileName+".json"); + InputStream refreshToken = resource.getInputStream(); + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + if(firebaseAppList != null && !firebaseAppList.isEmpty()){ + for(FirebaseApp app : firebaseAppList) { + if(app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + firebaseApp = FirebaseApp.initializeApp(options); + } + return FirebaseMessaging.getInstance(firebaseApp); + } +} diff --git a/src/main/java/igoMoney/BE/common/config/RedisConfig.java b/src/main/java/igoMoney/BE/common/config/RedisConfig.java new file mode 100644 index 0000000..c50c8f3 --- /dev/null +++ b/src/main/java/igoMoney/BE/common/config/RedisConfig.java @@ -0,0 +1,30 @@ +package igoMoney.BE.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/src/main/java/igoMoney/BE/common/exception/ErrorCode.java b/src/main/java/igoMoney/BE/common/exception/ErrorCode.java index 7227554..19c5c5d 100644 --- a/src/main/java/igoMoney/BE/common/exception/ErrorCode.java +++ b/src/main/java/igoMoney/BE/common/exception/ErrorCode.java @@ -25,6 +25,7 @@ public enum ErrorCode { ID_TOKEN_INVALID_4(HttpStatus.UNAUTHORIZED, "ID토큰이 유효하지 않습니다.(서명 검증 결과)"), ID_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "ID토큰이 만료되었습니다."), AUTH_CODE_INVALID(HttpStatus.UNAUTHORIZED, "Authorization Code가 유효하지 않습니다."), + FCM_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "FCM토큰이 null입니다."), // login 예외 LOGIN_CONNECTION_ERROR(HttpStatus.BAD_REQUEST, "로그인 요청 오류"), diff --git a/src/main/java/igoMoney/BE/common/jwt/dto/TokenDto.java b/src/main/java/igoMoney/BE/common/jwt/dto/TokenDto.java index 87c265d..1fe6ae5 100644 --- a/src/main/java/igoMoney/BE/common/jwt/dto/TokenDto.java +++ b/src/main/java/igoMoney/BE/common/jwt/dto/TokenDto.java @@ -13,4 +13,5 @@ public class TokenDto { // 서버 자체 토큰 private String refreshToken; private String provider_accessToken; private Long userId; + private String fcmToken; } diff --git a/src/main/java/igoMoney/BE/controller/AuthController.java b/src/main/java/igoMoney/BE/controller/AuthController.java index 790ff45..bc7a778 100644 --- a/src/main/java/igoMoney/BE/controller/AuthController.java +++ b/src/main/java/igoMoney/BE/controller/AuthController.java @@ -1,15 +1,14 @@ package igoMoney.BE.controller; +import igoMoney.BE.common.exception.CustomException; +import igoMoney.BE.common.exception.ErrorCode; import igoMoney.BE.common.jwt.AppleJwtUtils; import igoMoney.BE.common.jwt.dto.AppleSignOutRequest; import igoMoney.BE.common.jwt.dto.AppleTokenResponse; import igoMoney.BE.common.jwt.dto.TokenDto; -import igoMoney.BE.dto.request.FromAppleService; +import igoMoney.BE.dto.request.AuthAppleLoginRequest; import igoMoney.BE.dto.response.AuthRecreateTokenResponse; -import igoMoney.BE.service.AuthService; -import igoMoney.BE.service.ChallengeService; -import igoMoney.BE.service.RecordService; -import igoMoney.BE.service.RefreshTokenService; +import igoMoney.BE.service.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -33,14 +32,17 @@ public class AuthController { private final RecordService recordService; private final AppleJwtUtils appleJwtUtils; private final RefreshTokenService refreshTokenService; + private final FCMTokenService fcmTokenService; // 카카오 로그인 @PostMapping("login/kakao") @ResponseBody - public ResponseEntity kakaoLogin(@RequestBody TokenDto accessToken) throws IOException { + public ResponseEntity kakaoLogin(@RequestBody TokenDto token) throws IOException { - TokenDto response = authService.kakaoLogin(accessToken.getAccessToken()); + checkFCMToken(token.getFcmToken()); + TokenDto response = authService.kakaoLogin(token.getAccessToken()); refreshTokenService.saveRefreshToken(response); + fcmTokenService.saveToken(response.getUserId(), token.getFcmToken()); return ResponseEntity.status(HttpStatus.OK).body(response); } @@ -75,16 +77,18 @@ public String appleLoginPage(ModelMap model) { // 2. 로그인 후 Identity Token, AuthorizationCode 받기 @PostMapping("login/apple/redirect") @ResponseBody - public ResponseEntity getAppleUserIdToken(@RequestBody FromAppleService fromAppleService) throws Exception { + public ResponseEntity getAppleUserIdToken(@RequestBody AuthAppleLoginRequest authAppleLoginRequest) throws Exception { + checkFCMToken(authAppleLoginRequest.getFCMToken()); // 3. public key 요청하기 (n, e 값 받고 키 생성) // 4. Identity Token (JWT) 검증하기 // 5. ID토큰 payload 바탕으로 회원가입 - List subNemail = appleJwtUtils.checkIdToken(fromAppleService.getId_token()); + List subNemail = appleJwtUtils.checkIdToken(authAppleLoginRequest.getId_token()); // DB에 data에서 받아온 정보를 가진 사용자가 있는지 조회 & 회원가입 // 6. 서버에서 직접 JWT 토큰 발급하기 (access & refresh token) TokenDto response = authService.AppleSignUp(subNemail); // sub, email refreshTokenService.saveRefreshToken(response); + fcmTokenService.saveToken(response.getUserId(), authAppleLoginRequest.getFCMToken()); return ResponseEntity.status(HttpStatus.OK).body(response); } @@ -92,7 +96,7 @@ public ResponseEntity getAppleUserIdToken(@RequestBody FromAppleServic // [애플] 회원탈퇴 @PostMapping("signout/apple") @ResponseBody - public ResponseEntity appleSignOut(@RequestBody FromAppleService request) throws IOException { + public ResponseEntity appleSignOut(@RequestBody AuthAppleLoginRequest request) throws IOException { // 챌린지 중단 및 패배처리 challengeService.giveUpChallengeSignOut(request.getUserId()); @@ -111,6 +115,7 @@ public ResponseEntity appleSignOut(@RequestBody FromAppleService request) .token(response.getRefresh_token()) .build(); authService.appleSignOut(appleRequest); + fcmTokenService.deleteToken(request.getUserId()); return new ResponseEntity(HttpStatus.OK); } @@ -126,6 +131,7 @@ public ResponseEntity kakaoSignOut(@PathVariable Long userId){ recordService.deleteAllUserRecords(userId); // 카카오 연동해제 및 회원정보(User) 삭제 authService.kakaoSignOut(userId); + fcmTokenService.deleteToken(userId); return new ResponseEntity(HttpStatus.OK); } @@ -145,7 +151,14 @@ public ResponseEntity refreshToken(@RequestBody Map logout(@RequestBody Map accessToken) { // // authService.logout(accessToken.get("accessToken")); +// fcmService.deleteToken(userId); // // return new ResponseEntity(HttpStatus.OK); // } + + public void checkFCMToken(String fcmToken){ + if(fcmToken == null){ + throw new CustomException(ErrorCode.FCM_TOKEN_INVALID); + } + } } diff --git a/src/main/java/igoMoney/BE/dto/request/AuthAppleLoginRequest.java b/src/main/java/igoMoney/BE/dto/request/AuthAppleLoginRequest.java index 032e98c..e8697ad 100644 --- a/src/main/java/igoMoney/BE/dto/request/AuthAppleLoginRequest.java +++ b/src/main/java/igoMoney/BE/dto/request/AuthAppleLoginRequest.java @@ -1,15 +1,19 @@ package igoMoney.BE.dto.request; -//import jakarta.validation.constraints.NotNull; import lombok.*; +@NoArgsConstructor +@AllArgsConstructor +@Builder @Getter @Setter public class AuthAppleLoginRequest { - private String id; - private String email; - private String nickname; - private String picture ; + private String state; private String code; + private String id_token; + private String user; + private String refresh_token; + private Long userId; + private String FCMToken; } diff --git a/src/main/java/igoMoney/BE/dto/request/FromAppleService.java b/src/main/java/igoMoney/BE/dto/request/FromAppleService.java deleted file mode 100644 index 4e79300..0000000 --- a/src/main/java/igoMoney/BE/dto/request/FromAppleService.java +++ /dev/null @@ -1,18 +0,0 @@ -package igoMoney.BE.dto.request; - -import lombok.*; - -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Getter -@Setter -public class FromAppleService { - - private String state; - private String code; - private String id_token; - private String user; - private String refresh_token; - private Long userId; -} diff --git a/src/main/java/igoMoney/BE/service/AuthService.java b/src/main/java/igoMoney/BE/service/AuthService.java index e5e7d30..d315d07 100644 --- a/src/main/java/igoMoney/BE/service/AuthService.java +++ b/src/main/java/igoMoney/BE/service/AuthService.java @@ -169,7 +169,7 @@ public TokenDto kakaoLogin(String accessToken) throws IOException { String id = element.getAsJsonObject().get("id").getAsString(); // 카카오 회원번호 String email = ""; String image = ""; - //String nickname = ""; + //String nickname = "" email = element.getAsJsonObject().get("kakao_account").getAsJsonObject().get("email").getAsString(); image = element.getAsJsonObject().get("properties").getAsJsonObject().get("profile_image").getAsString(); //nickname = element.getAsJsonObject().get("kakao_account").getAsJsonObject().get("profile").getAsJsonObject().get("nickname").getAsString(); diff --git a/src/main/java/igoMoney/BE/service/ChallengeService.java b/src/main/java/igoMoney/BE/service/ChallengeService.java index 3948696..aa74d2d 100644 --- a/src/main/java/igoMoney/BE/service/ChallengeService.java +++ b/src/main/java/igoMoney/BE/service/ChallengeService.java @@ -32,7 +32,7 @@ public class ChallengeService { private final UserRepository userRepository; private final RecordRepository recordRepository; private final ChallengeUserRepository challengeUserRepository; - private final NotificationRepository notificationRepository; + private final NotificationService notificationService; DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("MM월 dd일"); // 시작 안 한 챌린지 목록 조회 @@ -148,7 +148,7 @@ public void applyChallenge(Long userId, Long challengeId) { .title("챌린지 현황") .message(findChallenge.getStartDate().format(dateFormat)+"부터 "+findUser.getNickname()+"님과 챌린지 시작") .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } @@ -200,7 +200,7 @@ public void cancelChallenge(User user, Integer sel) { .title("챌린지 결과") .message("상대방 "+ user.getNickname() +"님이 챌린지를 포기했어요.") .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } else if (sel==1){ Notification notification = Notification.builder() @@ -208,7 +208,7 @@ else if (sel==1){ .title("챌린지 결과") .message(user2.getNickname()+"님! 상대방 "+ user.getNickname() +"님이 3일 연속 미출석으로 패배하셨어요.") .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } else if (sel==2){ Notification notification = Notification.builder() @@ -216,7 +216,7 @@ else if (sel==2){ .title("챌린지 결과") .message(user2.getNickname()+"님! 상대방 "+ user.getNickname() +"님이 신고 누적으로 패배하셨어요.") .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } } @@ -292,14 +292,14 @@ else if (((BigDecimal) obj[1]).intValue() < minCost){ .title("챌린지 결과") .message(u.getNickname()+"님! "+lose.getNickname()+"님과의 챌린지 대결에서 승리하셔서 뱃지를 획득하게 되었어요. \uD83E\uDD47") // 🥇 .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } else{ Notification notification = Notification.builder() .user(u) .title("챌린지 결과") .message(u.getNickname()+"님! "+findWinner.getNickname()+"님과의 챌린지 대결에서 아쉽게 승리하지 못했어요. 새로운 챌린지를 도전해보세요. \uD83D\uDE25") //😥 .build(); - notificationRepository.save(notification); + notificationService.makeNotification(notification); } } @@ -326,7 +326,7 @@ public void checkAttendance() { .title("챌린지 결과") .message(u.getNickname()+"님! 지출내역을 3일 동안 인증하지 않아서 해당 챌린지에서 패배하셨어요.") .build(); - notificationRepository.save(absentNotification); + notificationService.makeNotification(absentNotification); if(check==1){ // 유저 둘 다 미출석 u.deleteBadge(); u.deleteBadge(); diff --git a/src/main/java/igoMoney/BE/service/FCMTokenService.java b/src/main/java/igoMoney/BE/service/FCMTokenService.java new file mode 100644 index 0000000..376dd78 --- /dev/null +++ b/src/main/java/igoMoney/BE/service/FCMTokenService.java @@ -0,0 +1,49 @@ +package igoMoney.BE.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import igoMoney.BE.domain.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class FCMTokenService { + + private final StringRedisTemplate tokenRedisTemplate; + + public void sendNotification(Notification notification) { + Long userId = notification.getUser().getId(); + if(!hasKey(userId)){ return;} + String token = getToken(userId); + Message message = Message.builder() + .putData("title", notification.getTitle()) + .putData("content", notification.getMessage()) + .putData("userId", String.valueOf(userId)) + .setToken(token) + .build(); + sendMessage(message); + } + + public void saveToken(Long userId, String FCMToken){ + tokenRedisTemplate.opsForValue() + .set(String.valueOf(userId), FCMToken); + } + + private String getToken(Long userId) { + return tokenRedisTemplate.opsForValue().get(userId); + } + + public void deleteToken(Long userId) { + tokenRedisTemplate.delete(String.valueOf(userId)); + } + + public boolean hasKey(Long userId){ + return tokenRedisTemplate.hasKey(String.valueOf(userId)); + } + + private void sendMessage(Message message) { + FirebaseMessaging.getInstance().sendAsync(message); + } +} diff --git a/src/main/java/igoMoney/BE/service/NotificationService.java b/src/main/java/igoMoney/BE/service/NotificationService.java new file mode 100644 index 0000000..1b55e94 --- /dev/null +++ b/src/main/java/igoMoney/BE/service/NotificationService.java @@ -0,0 +1,24 @@ +package igoMoney.BE.service; + +import igoMoney.BE.domain.Notification; +import igoMoney.BE.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final FCMTokenService fcmTokenService; + + public void makeNotification(Notification notification){ + notificationRepository.save(notification); + fcmTokenService.sendNotification(notification); + } + +} diff --git a/src/main/java/igoMoney/BE/service/RecordService.java b/src/main/java/igoMoney/BE/service/RecordService.java index 4c31961..8471c66 100644 --- a/src/main/java/igoMoney/BE/service/RecordService.java +++ b/src/main/java/igoMoney/BE/service/RecordService.java @@ -32,7 +32,7 @@ public class RecordService { private final UserRepository userRepository; private final ChallengeRepository challengeRepository; private final UserReportRepository userReportRepository; - private final NotificationRepository notificationRepository; + private final NotificationService notificationService; private final ImageService imageService; private final ChallengeService challengeService; @@ -153,7 +153,7 @@ public Long reportRecord(RecordReportRequest request) { .title("챌린지 현황") .message(offender.getNickname()+"님 지출 내역은 가이드라인 위반으로 인해 삭제 되었어요. 신고 내용 확인 후 조치가 취해질 예정입니다. 신고 사유: "+reportReasons.get(request.getReason())) .build(); - notificationRepository.save(reportNotification); + notificationService.makeNotification(reportNotification); // hide record Record record = getRecordOrThrow(request.getRecordId()); @@ -168,7 +168,7 @@ public Long reportRecord(RecordReportRequest request) { .title("챌린지 결과") .message(offender.getNickname()+"님은 신고 누적으로 진행중인 챌린지는 패배처리되고 일주일 동안 챌린지 참여가 제한됩니다. 제한 해제 날짜: " + offender.getBanReleaseDate().format(dateFormat)) .build(); - notificationRepository.save(banNotification); + notificationService.makeNotification(banNotification); challengeService.cancelChallenge(offender, 2); } return userReport.getId(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6d4dbc9..b5cd9f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: show-sql: false generate-ddl: true hibernate: - ddl-auto: create-drop + ddl-auto: update properties: hibernate: format_sql: true @@ -80,6 +80,10 @@ spring: apple: authorizationUri: https://appleid.apple.com/auth/authorize?response_mode=form_post tokenUri: https://appleid.apple.com/auth/token + data: + redis: + port: ${spring.data.redis.port} + host: ${spring.data.redis.host} jwt: secret: ${application.jwt.secret} diff --git a/src/test/java/igoMoney/BE/service/RedisTest.java b/src/test/java/igoMoney/BE/service/RedisTest.java new file mode 100644 index 0000000..b31effd --- /dev/null +++ b/src/test/java/igoMoney/BE/service/RedisTest.java @@ -0,0 +1,38 @@ +package igoMoney.BE.service; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class RedisTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Test + public void testSet() { + // given + SetOperations setOperations = redisTemplate.opsForSet(); + String key = "setKey"; + + // when + setOperations.add(key, "h", "e", "l", "l", "o"); + + // then + Set members = setOperations.members(key); + Long size = setOperations.size(key); + + assertThat(members).containsOnly("h", "e", "l", "o"); + assertThat(size).isEqualTo(4); + } +}