From 4e39fe4e0aa0ff769ce79b9d9b25f6bfc4f3c668 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:28:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20#126=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 16 ++- .../domain/member/dto/MemberRequest.java | 1 + .../DeviceTokenRedisRepository.java | 36 +++++ .../domain/member/service/MemberService.java | 7 +- .../member/service/MemberServiceImpl.java | 21 ++- .../DeviceTokenRedisRepositoryTest.java | 134 ++++++++++++++++++ 6 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java create mode 100644 src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java diff --git a/src/main/java/com/server/capple/domain/member/controller/MemberController.java b/src/main/java/com/server/capple/domain/member/controller/MemberController.java index da8757f9..3d01510c 100644 --- a/src/main/java/com/server/capple/domain/member/controller/MemberController.java +++ b/src/main/java/com/server/capple/domain/member/controller/MemberController.java @@ -84,8 +84,8 @@ public BaseResponse deleteOrphanageImages() """, description = "서버측의 애플 서버 접근 클라언트 관련 문제로 서버 관리자에게 문의해주세요."), })), }) - public BaseResponse login(@RequestParam String code) { - return BaseResponse.onSuccess(memberService.signIn(code)); + public BaseResponse login(@RequestParam String code, @RequestParam String deviceToken) { + return BaseResponse.onSuccess(memberService.signIn(code, deviceToken)); } @Operation(summary = "회원가입 API", description = "회원가입 API 입니다." + @@ -94,15 +94,15 @@ public BaseResponse login(@RequestParam String co "회원가입 성공시 accessToken과 refreshToken이 반환됩니다.") @PostMapping("/sign-up") public BaseResponse signUp(@RequestBody MemberRequest.signUp request) { - return BaseResponse.onSuccess(memberService.signUp(request.getSignUpToken(), request.getEmail(), request.getNickname(), request.getProfileImage())); + return BaseResponse.onSuccess(memberService.signUp(request.getSignUpToken(), request.getEmail(), request.getNickname(), request.getProfileImage(), request.getDeviceToken())); } @Operation(summary = "테스트용 로컬 로그인 API", description = "테스트용 로컬 로그인 API 입니다." + "쿼리 파라미터를 이용해 테스트용 아이디를 입력해주세요." + "refreshToken의 위치에 signUpToken이 반환됩니다.") @GetMapping("/local-sign-in") - public BaseResponse localLogin(@RequestParam String testId) { - return BaseResponse.onSuccess(memberService.localSignIn(testId)); + public BaseResponse localLogin(@RequestParam String testId, @RequestParam String deviceToken) { + return BaseResponse.onSuccess(memberService.localSignIn(testId, deviceToken)); } @Operation(summary = "회원탈퇴 API", description = "회원탈퇴 API 입니다.") @@ -187,4 +187,10 @@ public BaseResponse registerEmailWhitelist( @RequestParam Long whitelistDurationMinutes) { return BaseResponse.onSuccess(memberService.registerEmailWhitelist(mail, whitelistDurationMinutes)); } + + @Operation(summary = "로그아웃 API", description = "로그아웃 API 입니다.
저장된 디바이스 토큰을 지웁니다.") + @GetMapping("/logout") + public BaseResponse logout(@AuthMember Member member) { + return BaseResponse.onSuccess(memberService.logout(member)); + } } diff --git a/src/main/java/com/server/capple/domain/member/dto/MemberRequest.java b/src/main/java/com/server/capple/domain/member/dto/MemberRequest.java index fde93ee5..68b4d553 100644 --- a/src/main/java/com/server/capple/domain/member/dto/MemberRequest.java +++ b/src/main/java/com/server/capple/domain/member/dto/MemberRequest.java @@ -30,6 +30,7 @@ public static class signUp { private String email; private String nickname; private String profileImage; + private String deviceToken; } } diff --git a/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java b/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java new file mode 100644 index 00000000..0993ff2c --- /dev/null +++ b/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java @@ -0,0 +1,36 @@ +package com.server.capple.domain.member.repository; + + +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class DeviceTokenRedisRepository { + public static final String DEVICE_TOKEN_KEY = "device-token-"; + + @Resource(name = "redisTemplate") + private ValueOperations valueOperations; + private final RedisTemplate redisTemplate; + + public void saveDeviceToken(String key, String deviceToken) { + valueOperations.set(DEVICE_TOKEN_KEY + key, deviceToken); + } + + public String getDeviceToken(String key) { + return valueOperations.get(DEVICE_TOKEN_KEY + key); + } + + public List getDeviceTokens(List keys) { + return valueOperations.multiGet(keys.stream().map(key -> DEVICE_TOKEN_KEY + key).toList()); + } + + public void deleteDeviceToken(String key) { + redisTemplate.delete(DEVICE_TOKEN_KEY + key); + } +} diff --git a/src/main/java/com/server/capple/domain/member/service/MemberService.java b/src/main/java/com/server/capple/domain/member/service/MemberService.java index b0203810..c72ebb8d 100644 --- a/src/main/java/com/server/capple/domain/member/service/MemberService.java +++ b/src/main/java/com/server/capple/domain/member/service/MemberService.java @@ -13,9 +13,9 @@ public interface MemberService { MemberResponse.EditMemberInfo editMemberInfo(Member member, MemberRequest.EditMemberInfo request); MemberResponse.ProfileImage uploadImage(MultipartFile image); MemberResponse.DeleteProfileImages deleteOrphanageImages(); - MemberResponse.SignInResponse signIn(String authorizationCode); - MemberResponse.Tokens signUp(String signUpToken, String email, String nickname, String profileImage); - MemberResponse.SignInResponse localSignIn(String testId); + MemberResponse.SignInResponse signIn(String authorizationCode, String deviceToken); + MemberResponse.Tokens signUp(String signUpToken, String email, String nickname, String profileImage, String deviceToken); + MemberResponse.SignInResponse localSignIn(String testId, String deviceToken); MemberResponse.Tokens changeRole(Long memberId, Role role); MemberResponse.MemberId resignMember (Member member); Boolean checkNickname(String nickname); @@ -23,4 +23,5 @@ public interface MemberService { Boolean registerEmailWhitelist(String email, Long whitelistDurationMinutes); Boolean sendCertMail(String signUpToken, String email); Boolean checkCertCode(String signUpToken, String email, String certCode); + Boolean logout(Member member); } diff --git a/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java b/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java index 234b3ca5..114b27fe 100644 --- a/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java @@ -11,6 +11,7 @@ import com.server.capple.domain.member.entity.Role; import com.server.capple.domain.member.mapper.MemberMapper; import com.server.capple.domain.member.mapper.TokensMapper; +import com.server.capple.domain.member.repository.DeviceTokenRedisRepository; import com.server.capple.domain.member.repository.MemberRepository; import com.server.capple.global.exception.RestApiException; import com.server.capple.global.exception.errorCode.AuthErrorCode; @@ -39,6 +40,7 @@ public class MemberServiceImpl implements MemberService { private final AppleAuthService appleAuthService; private final JwtService jwtService; private final MailService mailService; + private final DeviceTokenRedisRepository deviceTokenRedisRepository; @Override public MemberResponse.MyPageMemberInfo getMemberInfo(Member member) { @@ -93,7 +95,7 @@ public MemberResponse.DeleteProfileImages deleteOrphanageImages() { return new MemberResponse.DeleteProfileImages(deleteImages); } - public MemberResponse.SignInResponse signIn(String authorizationCode) { + public MemberResponse.SignInResponse signIn(String authorizationCode, String deviceToken) { AppleIdTokenPayload appleIdTokenPayload = appleAuthService.get(authorizationCode); Optional optionalMember = memberRepository.findBySub(appleIdTokenPayload.getSub()); if (optionalMember.isEmpty()) { @@ -104,12 +106,14 @@ public MemberResponse.SignInResponse signIn(String authorizationCode) { String role = optionalMember.get().getRole().getName(); String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); + if(deviceToken != null) + deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); return memberMapper.toSignInResponse(accessToken, refreshToken, true); } @Override @Transactional - public MemberResponse.Tokens signUp(String signUpToken, String email, String nickname, String profileImage) { + public MemberResponse.Tokens signUp(String signUpToken, String email, String nickname, String profileImage, String deviceToken) { String sub = jwtService.getSub(signUpToken); String encryptedEmail = convertEmailToJwt(email); @@ -120,11 +124,13 @@ public MemberResponse.Tokens signUp(String signUpToken, String email, String nic String role = member.getRole().getName(); String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); + if(deviceToken != null) + deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); return tokensMapper.toTokens(accessToken, refreshToken); } @Override - public MemberResponse.SignInResponse localSignIn(String testId) { + public MemberResponse.SignInResponse localSignIn(String testId, String deviceToken) { Optional optionalMember = memberRepository.findBySub(testId); if (optionalMember.isEmpty()) { String signUpToken = jwtService.createSignUpAccessJwt(testId); @@ -134,6 +140,8 @@ public MemberResponse.SignInResponse localSignIn(String testId) { String role = optionalMember.get().getRole().getName(); String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); + if(deviceToken != null) + deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); return memberMapper.toSignInResponse(accessToken, refreshToken, true); } @@ -153,6 +161,7 @@ public MemberResponse.MemberId resignMember(Member member) { Member resignedMember = memberRepository.findById(member.getId()).orElseThrow( () -> new RestApiException(MemberErrorCode.MEMBER_NOT_FOUND)); resignedMember.resignMember(); + deviceTokenRedisRepository.deleteDeviceToken(member.getId().toString()); return memberMapper.toMemberId(member); } @@ -212,4 +221,10 @@ public Boolean checkCertCode(String signUpToken, String email, String certCode) // 이메일 인증코드 체크 return mailService.checkEmailCertificationCode(emailJwt, certCode); } + + @Override + public Boolean logout(Member member) { + deviceTokenRedisRepository.deleteDeviceToken(member.getId().toString()); + return true; + } } \ No newline at end of file diff --git a/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java b/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java new file mode 100644 index 00000000..fe9ed909 --- /dev/null +++ b/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java @@ -0,0 +1,134 @@ +package com.server.capple.domain.member.repository; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("디바이스 토큰 레포지토리의 ") +@SpringBootTest +@ActiveProfiles("test") +class DeviceTokenRedisRepositoryTest { + @Autowired + private DeviceTokenRedisRepository deviceTokenRedisRepository; + + private String testKeyPrefix = "testKey-"; + private List testKeys = new ArrayList<>(); + + @AfterEach + void afterEach() { + testKeys.forEach(deviceTokenRedisRepository::deleteDeviceToken); + } + + @Test + @DisplayName("디바이스 토큰 저장, 조회 테스트") + void saveDeviceTokenTest() { + //given + String key = testKeyPrefix + UUID.randomUUID().toString(); + testKeys.add(key); + String deviceToken = UUID.randomUUID().toString(); + + //when + deviceTokenRedisRepository.saveDeviceToken(key, deviceToken); + + //then + assertEquals(deviceToken, deviceTokenRedisRepository.getDeviceToken(key)); + } + + @Test + @DisplayName("디바이스 토큰 삭제 테스트") + void deleteDeviceTokenTest() { + //given + String key = testKeyPrefix + UUID.randomUUID().toString(); + String deviceToken = UUID.randomUUID().toString(); + + //when + deviceTokenRedisRepository.saveDeviceToken(key, deviceToken); + deviceTokenRedisRepository.deleteDeviceToken(key); + + //then + assertNull(deviceTokenRedisRepository.getDeviceToken(key)); + } + + @Test + @DisplayName("디바이스 토큰 여러개 조회 테스트") + void getDeviceTokensTest() { + //given + String key1 = testKeyPrefix + UUID.randomUUID().toString(); + String key2 = testKeyPrefix + UUID.randomUUID().toString(); + String key3 = testKeyPrefix + UUID.randomUUID().toString(); + testKeys.add(key1); + testKeys.add(key2); + testKeys.add(key3); + String deviceToken1 = UUID.randomUUID().toString(); + String deviceToken2 = UUID.randomUUID().toString(); + String deviceToken3 = UUID.randomUUID().toString(); + + //when + deviceTokenRedisRepository.saveDeviceToken(key1, deviceToken1); + deviceTokenRedisRepository.saveDeviceToken(key2, deviceToken2); + deviceTokenRedisRepository.saveDeviceToken(key3, deviceToken3); + + //then + List deviceTokens = deviceTokenRedisRepository.getDeviceTokens(List.of(key1, key2, key3)); + assertEquals(3, deviceTokens.size()); + assertTrue(deviceTokens.contains(deviceToken1)); + assertTrue(deviceTokens.contains(deviceToken2)); + assertTrue(deviceTokens.contains(deviceToken3)); + } + + @Test + @DisplayName("디바이스 토큰 삭제 후 여러개 조회 테스트") + void getDeviceTokensAfterDeleteTest() { + //given + String key1 = testKeyPrefix + UUID.randomUUID().toString(); + String key2 = testKeyPrefix + UUID.randomUUID().toString(); + String key3 = testKeyPrefix + UUID.randomUUID().toString(); + testKeys.add(key1); + testKeys.add(key2); + testKeys.add(key3); + String deviceToken1 = UUID.randomUUID().toString(); + String deviceToken2 = UUID.randomUUID().toString(); + String deviceToken3 = UUID.randomUUID().toString(); + + //when + deviceTokenRedisRepository.saveDeviceToken(key1, deviceToken1); + deviceTokenRedisRepository.saveDeviceToken(key2, deviceToken2); + deviceTokenRedisRepository.saveDeviceToken(key3, deviceToken3); + deviceTokenRedisRepository.deleteDeviceToken(key2); + + //then + List deviceTokens = deviceTokenRedisRepository.getDeviceTokens(List.of(key1, key2, key3)); + assertEquals(3, deviceTokens.size()); + assertTrue(deviceTokens.contains(deviceToken1)); + assertFalse(deviceTokens.contains(deviceToken2)); + assertNull(deviceTokens.get(1)); + assertTrue(deviceTokens.contains(deviceToken3)); + } + + @Test + @DisplayName("디바이스 토큰 중복 저장, 조회 테스트") + void saveDuplicateDeviceTokenTest() { + //given + String key = testKeyPrefix + UUID.randomUUID().toString(); + testKeys.add(key); + String deviceToken1 = UUID.randomUUID().toString(); + String deviceToken2 = UUID.randomUUID().toString(); + + //when + deviceTokenRedisRepository.saveDeviceToken(key, deviceToken1); + deviceTokenRedisRepository.saveDeviceToken(key, deviceToken2); + + //then + assertNotEquals(deviceToken1, deviceTokenRedisRepository.getDeviceToken(key)); + assertEquals(deviceToken2, deviceTokenRedisRepository.getDeviceToken(key)); + } +} \ No newline at end of file From 0c4ae5b8aca17ace8129ff0480bcc7984fa67d03 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:48:54 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20#126=20APNs=20JWT=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BA=90=EC=8B=B1=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../com/server/capple/CappleApplication.java | 2 + .../com/server/capple/config/RedisConfig.java | 15 +++++++ .../security/jwt/service/JwtService.java | 1 + .../security/jwt/service/JwtServiceImpl.java | 39 ++++++++++++++++++- 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/config b/config index b3f9b9eb..aa26e0d5 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit b3f9b9eb11640d8a78212a230b45dc0356f8d8f2 +Subproject commit aa26e0d5147da13220e6505f619204812bc0586b diff --git a/src/main/java/com/server/capple/CappleApplication.java b/src/main/java/com/server/capple/CappleApplication.java index ed9f5bba..bcd0e14b 100644 --- a/src/main/java/com/server/capple/CappleApplication.java +++ b/src/main/java/com/server/capple/CappleApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @@ -16,6 +17,7 @@ @EnableFeignClients @EnableConfigurationProperties @EnableScheduling +@EnableCaching public class CappleApplication { public static void main(String[] args) { diff --git a/src/main/java/com/server/capple/config/RedisConfig.java b/src/main/java/com/server/capple/config/RedisConfig.java index a330bdcd..f7eb58d3 100644 --- a/src/main/java/com/server/capple/config/RedisConfig.java +++ b/src/main/java/com/server/capple/config/RedisConfig.java @@ -1,14 +1,20 @@ package com.server.capple.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; + @Configuration public class RedisConfig { @@ -35,4 +41,13 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + + @Bean + public CacheManager apnsJwtCacheManager(RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofMinutes(30)); + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build(); + } } diff --git a/src/main/java/com/server/capple/config/security/jwt/service/JwtService.java b/src/main/java/com/server/capple/config/security/jwt/service/JwtService.java index 9d7934af..92f0fe13 100644 --- a/src/main/java/com/server/capple/config/security/jwt/service/JwtService.java +++ b/src/main/java/com/server/capple/config/security/jwt/service/JwtService.java @@ -16,4 +16,5 @@ public interface JwtService { Boolean isExpired(String token); Boolean checkJwt(String token); MemberResponse.Tokens refreshTokens(Long memberId, Role role); + String createApnsJwt(); } diff --git a/src/main/java/com/server/capple/config/security/jwt/service/JwtServiceImpl.java b/src/main/java/com/server/capple/config/security/jwt/service/JwtServiceImpl.java index 1267dff7..a138eaf2 100644 --- a/src/main/java/com/server/capple/config/security/jwt/service/JwtServiceImpl.java +++ b/src/main/java/com/server/capple/config/security/jwt/service/JwtServiceImpl.java @@ -11,6 +11,10 @@ import io.jsonwebtoken.*; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -18,16 +22,26 @@ import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.Security; +import java.util.Base64; import java.util.Date; @Component @RequiredArgsConstructor -public class JwtServiceImpl implements JwtService{ +public class JwtServiceImpl implements JwtService { private SecretKey secretKey; private final JwtProperties jwtProperties; private final JpaUserDetailService userDetailService; private final TokensMapper tokensMapper; + @Value("${apns.key-id}") + private String kid; + @Value("${apple-auth.team_id}") + private String iss; + @Value("${apns.key}") + private String apnsKeyString; + @PostConstruct protected void init() { secretKey = new SecretKeySpec(jwtProperties.getJwt_secret().getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS512.key().build().getAlgorithm()); @@ -119,4 +133,27 @@ public MemberResponse.Tokens refreshTokens(Long memberId, Role role) { String refreshToken = createJwt(memberId, role.getName(), "refresh"); return tokensMapper.toTokens(accessToken, refreshToken); } + + @Override + @Cacheable(value = "ApnsJwt", cacheManager = "apnsJwtCacheManager") + public String createApnsJwt() { + return Jwts.builder() + .header().add("alg", "ES256").add("kid", kid).and() + .issuer(iss) + .issuedAt(new Date(System.currentTimeMillis())) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } + + private PrivateKey getPrivateKey() { + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + try { + byte[] privateKeyBytes = Base64.getDecoder().decode(apnsKeyString); + PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes); + return converter.getPrivateKey(privateKeyInfo); + } catch (Exception e) { + throw new RuntimeException("Error converting private key from String", e); + } + } } From 38c43d33ea4b169bf262e7edc9a4fe44f1fd6ca3 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Thu, 22 Aug 2024 18:04:03 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20#126=20APNs=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B0=9C=EC=86=A1=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + config | 2 +- .../config/apns/config/ApnsClientConfig.java | 47 ++++++++++ .../config/apns/dto/ApnsClientRequest.java | 93 +++++++++++++++++++ .../config/apns/service/ApnsService.java | 7 ++ .../config/apns/service/ApnsServiceImpl.java | 74 +++++++++++++++ .../apns/service/ApnsServiceImplTest.java | 63 +++++++++++++ 7 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java create mode 100644 src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java create mode 100644 src/main/java/com/server/capple/config/apns/service/ApnsService.java create mode 100644 src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java create mode 100644 src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java diff --git a/build.gradle b/build.gradle index a987461c..1d26fe12 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,8 @@ dependencies { // https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305 implementation 'com.google.code.findbugs:jsr305:3.0.2' +// webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' } dependencyManagement { diff --git a/config b/config index aa26e0d5..de326242 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit aa26e0d5147da13220e6505f619204812bc0586b +Subproject commit de3262429af531867ffdfba0b0fe6b152864982d diff --git a/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java b/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java new file mode 100644 index 00000000..c1003b09 --- /dev/null +++ b/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java @@ -0,0 +1,47 @@ +package com.server.capple.config.apns.config; + +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.netty.http.HttpProtocol; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; + +import javax.net.ssl.SSLException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Configuration +public class ApnsClientConfig { + @Bean("apnsSslContext") + public SslContext getApnsSslContext() throws SSLException { + return SslContextBuilder.forClient().protocols("TLSv1.2").build(); // SSL 설정 + } + + @Bean("apnsConnectionProvider") + public ConnectionProvider getApnsConnectionProvider() { + return ConnectionProvider.builder("apns") + .maxConnections(10) // 최대 커낵션 수 + .pendingAcquireMaxCount(-1) // 재시도 횟수 (-1 : 무한대) + .pendingAcquireTimeout(java.time.Duration.ofSeconds(10)) // 커넥션 풀에 사용 가능한 커넥션 없을 때의 대기 시간 + .maxIdleTime(java.time.Duration.ofSeconds(5)) // 최대 유휴 시간 + .maxLifeTime(java.time.Duration.ofSeconds(300)) // 최대 생명 시간 + .lifo() // 후입선출 + .build(); + } + + @Bean("apnsH2HttpClient") + public HttpClient getApnsH2HttpClient(ConnectionProvider apnsConnectionProvider, SslContext apnsSslContext) { + return HttpClient.create(apnsConnectionProvider) // reactor HttpClient 생성 + .keepAlive(true) // keep-alive 활성화 + .protocol(HttpProtocol.H2) // HTTP/2 활성화 + .doOnConnected(connection -> connection.addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))) // 쓰기 타임 아웃 + .doAfterRequest((req, conn) -> conn.addHandlerLast(new LoggingHandler(LogLevel.INFO))) + .responseTimeout(Duration.ofSeconds(10)) // 응답 타임 아웃 + .secure(sslSpec -> sslSpec.sslContext(apnsSslContext)); // SSL 활성화 + } +} diff --git a/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java new file mode 100644 index 00000000..1af62a3c --- /dev/null +++ b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java @@ -0,0 +1,93 @@ +package com.server.capple.config.apns.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +public class ApnsClientRequest { + + @Getter + @NoArgsConstructor + @ToString + public static class SimplePushBody { + private Aps aps; + + public SimplePushBody(String title, String subTitle, String body, Integer badge, String threaId, String targetContentId) { + this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, threaId, targetContentId); + } + + @ToString + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Aps { + private Alert alert; + private Integer badge; + @JsonProperty("thread-id") + private String threadId; + @JsonProperty("target-content-id") + private String targetContentId; // 프론트 측 작업 필요함 + + @ToString + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Alert { + private String title; + private String subtitle; + private String body; + } + } + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor + @Builder + @ToString + public static class FullAlertBody { + private Aps aps; + + @Getter + @AllArgsConstructor + @NoArgsConstructor + @Builder + @ToString + public static class Aps { + private Alert alert; // alert 정보 + private Integer badge; // 앱 아이콘에 표시할 뱃지 숫자 + @Schema(defaultValue = "default") + private String sound; // Library/Sounds 폴더 내의 파일 이름 + @Schema(defaultValue = "thread-id") + private String threadId; // 알림 그룹화를 위한 thread id (UNNotificationContent 객체의 threadIdentifier와 일치해야 함) + private String category; // 알림 그룹화를 위한 category, (UNNotificationCategory 식별자와 일치해야 함) + @Schema(defaultValue = "0") + @JsonProperty("content-available") + private Integer contentAvailable; // 백그라운드 알림 여부, 1이면 백그라운드 알림, 0이면 포그라운드 알림 (백그라운드일 경우 alert, badge, sound는 넣으면 안됨) + @Schema(defaultValue = "0") + @JsonProperty("mutable-content") + private Integer mutableContent; // 알림 서비스 확장 플래그 + @Schema(defaultValue = "") + @JsonProperty("target-content-id") + private String targetContentId; // 알림이 클릭되었을 때 가져올 창의 식별자, UNNotificationContent 객체에 채워짐 + + @Getter + @AllArgsConstructor + @NoArgsConstructor + @Builder + @ToString + public static class Alert { + @Schema(defaultValue = "title") + private String title; + @Schema(defaultValue = "subTitle") + private String subtitle; + @Schema(defaultValue = "body") + private String body; + @Schema(defaultValue = "") + @JsonProperty("launch-image") + private String launchImage; // 실행시 보여줄 이미지 파일, 기본 실행 이미지 대신 입력한 이미지 또는 스토리보드가 켜짐 + } + } + } + +} diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsService.java b/src/main/java/com/server/capple/config/apns/service/ApnsService.java new file mode 100644 index 00000000..ece346db --- /dev/null +++ b/src/main/java/com/server/capple/config/apns/service/ApnsService.java @@ -0,0 +1,7 @@ +package com.server.capple.config.apns.service; + +import java.util.List; + +public interface ApnsService { + Boolean sendApns(T request, List deviceToken); +} diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java new file mode 100644 index 00000000..17ab9fb3 --- /dev/null +++ b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java @@ -0,0 +1,74 @@ +package com.server.capple.config.apns.service; + +import com.server.capple.config.security.jwt.service.JwtService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ApnsServiceImpl implements ApnsService { + private final JwtService jwtService; + private final HttpClient apnsH2HttpClient; + private WebClient defaultApnsWebClient; + + @Value("${apns.base-url}") + private String apnsBaseUrl; + @Value("${apns.base-sub-url}") + private String apnsBaseSubUrl; + @Value("${apple-auth.client_id}") + private String apnsTopic; + private final String apnsAlertPushType = "alert"; + + @PostConstruct + public void init() { + defaultApnsWebClient = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(apnsH2HttpClient)) + .baseUrl(apnsBaseUrl) + .defaultHeader("apns-topic", apnsTopic) + .defaultHeader("apns-push-type", apnsAlertPushType) + .build(); + } + + @Override + public Boolean sendApns(T request, List deviceToken) { + WebClient tmpWebClient = defaultApnsWebClient.mutate() + .defaultHeader("authorization", "bearer " + jwtService.createApnsJwt()) + .build(); + + WebClient tmpSubWebClient = tmpWebClient.mutate() + .baseUrl(apnsBaseSubUrl) + .build(); + + deviceToken.parallelStream().forEach(token -> { + tmpWebClient + .method(HttpMethod.POST) + .uri(token) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .doOnError(e -> { // 에러 발생 시 보조 채널로 재시도 + tmpSubWebClient + .method(HttpMethod.POST) + .uri(token) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .block(); + log.error("APNs 전송 중 오류 발생", e); + }) + .block(); + }); + + return true; + } +} diff --git a/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java b/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java new file mode 100644 index 00000000..58f1d90b --- /dev/null +++ b/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java @@ -0,0 +1,63 @@ +package com.server.capple.config.apns.service; + +import com.server.capple.config.apns.dto.ApnsClientRequest.SimplePushBody; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@DisplayName("APNs 서비스로 ") +@SpringBootTest +@ActiveProfiles("test") +class ApnsServiceImplTest { + @Autowired + private ApnsService apnsService; + + @Test + @Disabled + @DisplayName("메시지를 전송한다.") + void sendApns() { + //given + String simulatorDeviceToken = "{deviceToken}"; // 기기의 deviceToken을 넣어야 작동 + String title = "title"; + String subTitle = "subTitle"; + String body = "body"; + String threadId = "testApnsMessage"; + String targetContentId = "targetContentId"; + + //when + Boolean result = apnsService.sendApns(new SimplePushBody(title, subTitle, body, null, threadId, targetContentId), List.of(simulatorDeviceToken)); + + //then + assertTrue(result); + } + + @Test + @Disabled + @DisplayName("다수의 메시지를 전송한다.") + void sendApnsMessages() { + //given + String simulatorDeviceToken = "{deviceToken}"; // 기기의 deviceToken을 넣어야 작동 + String title = "title"; + String subTitle = "subTitle"; + String body = "body"; + String threadId = "multipleTestApnsMessages"; + String targetContentId = "targetContentId"; + ArrayList deviceTokens = new ArrayList<>(); + for (int i = 0; i < 100; i++) deviceTokens.add(simulatorDeviceToken); + + //when + Boolean result = apnsService.sendApns(new SimplePushBody(title, subTitle, body, null, threadId, targetContentId), deviceTokens); + + //then + assertTrue(result); + } +} \ No newline at end of file From ad683d96223c24845d82c1510300d8aff3eb7e30 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Fri, 23 Aug 2024 00:07:27 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20#126=20APNs=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B0=9C=EC=86=A1=20Non-Blocking=20request?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/apns/config/ApnsClientConfig.java | 3 -- .../config/apns/service/ApnsServiceImpl.java | 40 +++++++++---------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java b/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java index c1003b09..d05f103d 100644 --- a/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java +++ b/src/main/java/com/server/capple/config/apns/config/ApnsClientConfig.java @@ -1,7 +1,5 @@ package com.server.capple.config.apns.config; -import io.netty.handler.logging.LogLevel; -import io.netty.handler.logging.LoggingHandler; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.timeout.WriteTimeoutHandler; @@ -40,7 +38,6 @@ public HttpClient getApnsH2HttpClient(ConnectionProvider apnsConnectionProvider, .keepAlive(true) // keep-alive 활성화 .protocol(HttpProtocol.H2) // HTTP/2 활성화 .doOnConnected(connection -> connection.addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))) // 쓰기 타임 아웃 - .doAfterRequest((req, conn) -> conn.addHandlerLast(new LoggingHandler(LogLevel.INFO))) .responseTimeout(Duration.ofSeconds(10)) // 응답 타임 아웃 .secure(sslSpec -> sslSpec.sslContext(apnsSslContext)); // SSL 활성화 } diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java index 17ab9fb3..60d423a8 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java @@ -49,26 +49,26 @@ public Boolean sendApns(T request, List deviceToken) { .baseUrl(apnsBaseSubUrl) .build(); - deviceToken.parallelStream().forEach(token -> { - tmpWebClient - .method(HttpMethod.POST) - .uri(token) - .bodyValue(request) - .retrieve() - .bodyToMono(String.class) - .doOnError(e -> { // 에러 발생 시 보조 채널로 재시도 - tmpSubWebClient - .method(HttpMethod.POST) - .uri(token) - .bodyValue(request) - .retrieve() - .bodyToMono(String.class) - .block(); - log.error("APNs 전송 중 오류 발생", e); - }) - .block(); - }); - + deviceToken.parallelStream() + .forEach(token -> { + tmpWebClient + .method(HttpMethod.POST) + .uri(token) + .bodyValue(request) + .retrieve() + .bodyToMono(Void.class) + .doOnError(e -> { // 에러 발생 시 보조 채널로 재시도 + tmpSubWebClient + .method(HttpMethod.POST) + .uri(token) + .bodyValue(request) + .retrieve() + .bodyToMono(Void.class) + .subscribe(); + log.error("APNs 전송 중 오류 발생", e); + }) + .subscribe(); + }); return true; } } From df4af362e47996cf56906ee8114245776b6c2389 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Fri, 23 Aug 2024 00:12:23 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20#126=20APNs=20deviceToken=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20key=20=ED=83=80=EC=9E=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20memberId=20=EC=9D=B4=EC=9A=A9=20APNs=20?= =?UTF-8?q?=EB=B0=9C=EC=8B=A0=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/apns/service/ApnsService.java | 1 + .../config/apns/service/ApnsServiceImpl.java | 7 ++++++ .../DeviceTokenRedisRepository.java | 16 +++++++------- .../member/service/MemberServiceImpl.java | 10 ++++----- .../DeviceTokenRedisRepositoryTest.java | 22 +++++++++---------- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsService.java b/src/main/java/com/server/capple/config/apns/service/ApnsService.java index ece346db..4b262fa4 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsService.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsService.java @@ -4,4 +4,5 @@ public interface ApnsService { Boolean sendApns(T request, List deviceToken); + Boolean sendApnsToMembers(T request, List memberIdList); } diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java index 60d423a8..6be5cefa 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java @@ -1,6 +1,7 @@ package com.server.capple.config.apns.service; import com.server.capple.config.security.jwt.service.JwtService; +import com.server.capple.domain.member.repository.DeviceTokenRedisRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,6 +20,7 @@ public class ApnsServiceImpl implements ApnsService { private final JwtService jwtService; private final HttpClient apnsH2HttpClient; + private final DeviceTokenRedisRepository deviceTokenRedisRepository; private WebClient defaultApnsWebClient; @Value("${apns.base-url}") @@ -71,4 +73,9 @@ public Boolean sendApns(T request, List deviceToken) { }); return true; } + + @Override + public Boolean sendApnsToMembers(T request, List memberIdList) { + return sendApns(request, deviceTokenRedisRepository.getDeviceTokens(memberIdList)); + } } diff --git a/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java b/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java index 0993ff2c..994218ef 100644 --- a/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java +++ b/src/main/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepository.java @@ -18,19 +18,19 @@ public class DeviceTokenRedisRepository { private ValueOperations valueOperations; private final RedisTemplate redisTemplate; - public void saveDeviceToken(String key, String deviceToken) { - valueOperations.set(DEVICE_TOKEN_KEY + key, deviceToken); + public void saveDeviceToken(Long memberId, String deviceToken) { + valueOperations.set(DEVICE_TOKEN_KEY + memberId.toString(), deviceToken); } - public String getDeviceToken(String key) { - return valueOperations.get(DEVICE_TOKEN_KEY + key); + public String getDeviceToken(Long memberId) { + return valueOperations.get(DEVICE_TOKEN_KEY + memberId.toString()); } - public List getDeviceTokens(List keys) { - return valueOperations.multiGet(keys.stream().map(key -> DEVICE_TOKEN_KEY + key).toList()); + public List getDeviceTokens(List keys) { + return valueOperations.multiGet(keys.stream().map(key -> DEVICE_TOKEN_KEY + key.toString()).toList()); } - public void deleteDeviceToken(String key) { - redisTemplate.delete(DEVICE_TOKEN_KEY + key); + public void deleteDeviceToken(Long memberId) { + redisTemplate.delete(DEVICE_TOKEN_KEY + memberId.toString()); } } diff --git a/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java b/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java index 114b27fe..bc1d2073 100644 --- a/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/server/capple/domain/member/service/MemberServiceImpl.java @@ -107,7 +107,7 @@ public MemberResponse.SignInResponse signIn(String authorizationCode, String dev String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); if(deviceToken != null) - deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); + deviceTokenRedisRepository.saveDeviceToken(memberId, deviceToken); return memberMapper.toSignInResponse(accessToken, refreshToken, true); } @@ -125,7 +125,7 @@ public MemberResponse.Tokens signUp(String signUpToken, String email, String nic String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); if(deviceToken != null) - deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); + deviceTokenRedisRepository.saveDeviceToken(memberId, deviceToken); return tokensMapper.toTokens(accessToken, refreshToken); } @@ -141,7 +141,7 @@ public MemberResponse.SignInResponse localSignIn(String testId, String deviceTok String accessToken = jwtService.createJwt(memberId, role, "access"); String refreshToken = jwtService.createJwt(memberId, role, "refresh"); if(deviceToken != null) - deviceTokenRedisRepository.saveDeviceToken(memberId.toString(), deviceToken); + deviceTokenRedisRepository.saveDeviceToken(memberId, deviceToken); return memberMapper.toSignInResponse(accessToken, refreshToken, true); } @@ -161,7 +161,7 @@ public MemberResponse.MemberId resignMember(Member member) { Member resignedMember = memberRepository.findById(member.getId()).orElseThrow( () -> new RestApiException(MemberErrorCode.MEMBER_NOT_FOUND)); resignedMember.resignMember(); - deviceTokenRedisRepository.deleteDeviceToken(member.getId().toString()); + deviceTokenRedisRepository.deleteDeviceToken(member.getId()); return memberMapper.toMemberId(member); } @@ -224,7 +224,7 @@ public Boolean checkCertCode(String signUpToken, String email, String certCode) @Override public Boolean logout(Member member) { - deviceTokenRedisRepository.deleteDeviceToken(member.getId().toString()); + deviceTokenRedisRepository.deleteDeviceToken(member.getId()); return true; } } \ No newline at end of file diff --git a/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java b/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java index fe9ed909..34e5c4ff 100644 --- a/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java +++ b/src/test/java/com/server/capple/domain/member/repository/DeviceTokenRedisRepositoryTest.java @@ -20,8 +20,8 @@ class DeviceTokenRedisRepositoryTest { @Autowired private DeviceTokenRedisRepository deviceTokenRedisRepository; - private String testKeyPrefix = "testKey-"; - private List testKeys = new ArrayList<>(); + private Long testKeyPrefix = 1_000_000_000L; + private List testKeys = new ArrayList<>(); @AfterEach void afterEach() { @@ -32,7 +32,7 @@ void afterEach() { @DisplayName("디바이스 토큰 저장, 조회 테스트") void saveDeviceTokenTest() { //given - String key = testKeyPrefix + UUID.randomUUID().toString(); + Long key = testKeyPrefix + 1; testKeys.add(key); String deviceToken = UUID.randomUUID().toString(); @@ -47,7 +47,7 @@ void saveDeviceTokenTest() { @DisplayName("디바이스 토큰 삭제 테스트") void deleteDeviceTokenTest() { //given - String key = testKeyPrefix + UUID.randomUUID().toString(); + Long key = testKeyPrefix + 1; String deviceToken = UUID.randomUUID().toString(); //when @@ -62,9 +62,9 @@ void deleteDeviceTokenTest() { @DisplayName("디바이스 토큰 여러개 조회 테스트") void getDeviceTokensTest() { //given - String key1 = testKeyPrefix + UUID.randomUUID().toString(); - String key2 = testKeyPrefix + UUID.randomUUID().toString(); - String key3 = testKeyPrefix + UUID.randomUUID().toString(); + Long key1 = testKeyPrefix + 1; + Long key2 = testKeyPrefix + 2; + Long key3 = testKeyPrefix + 3; testKeys.add(key1); testKeys.add(key2); testKeys.add(key3); @@ -89,9 +89,9 @@ void getDeviceTokensTest() { @DisplayName("디바이스 토큰 삭제 후 여러개 조회 테스트") void getDeviceTokensAfterDeleteTest() { //given - String key1 = testKeyPrefix + UUID.randomUUID().toString(); - String key2 = testKeyPrefix + UUID.randomUUID().toString(); - String key3 = testKeyPrefix + UUID.randomUUID().toString(); + Long key1 = testKeyPrefix + 1; + Long key2 = testKeyPrefix + 2; + Long key3 = testKeyPrefix + 3; testKeys.add(key1); testKeys.add(key2); testKeys.add(key3); @@ -118,7 +118,7 @@ void getDeviceTokensAfterDeleteTest() { @DisplayName("디바이스 토큰 중복 저장, 조회 테스트") void saveDuplicateDeviceTokenTest() { //given - String key = testKeyPrefix + UUID.randomUUID().toString(); + Long key = testKeyPrefix + 1; testKeys.add(key); String deviceToken1 = UUID.randomUUID().toString(); String deviceToken2 = UUID.randomUUID().toString(); From ce0f663ada01604cbc1b72e69e4cf0971eebd76d Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:20:57 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20#126=20redis=20cloud=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../com/server/capple/config/RedisConfig.java | 31 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/config b/config index de326242..cdea0f32 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit de3262429af531867ffdfba0b0fe6b152864982d +Subproject commit cdea0f3251cf1bb6ec4b5d01939797da1b926510 diff --git a/src/main/java/com/server/capple/config/RedisConfig.java b/src/main/java/com/server/capple/config/RedisConfig.java index f7eb58d3..00582074 100644 --- a/src/main/java/com/server/capple/config/RedisConfig.java +++ b/src/main/java/com/server/capple/config/RedisConfig.java @@ -1,12 +1,15 @@ package com.server.capple.config; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; @@ -24,13 +27,25 @@ public class RedisConfig { private int port; @Value("${spring.data.redis.database}") private int database; + @Value("${redis-cloud.host}") + private String redisCloudHost; + @Value("${redis-cloud.port}") + private int redisCloudPort; + @Value("${redis-cloud.database}") + private int redisCloudDatabase; + @Value("${redis-cloud.username}") + private String redisCloudUsername; + @Value("${redis-cloud.password}") + private String redisCloudPassword; @Bean + @Primary public RedisConnectionFactory redisConnectionFactory() { LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(host, port); connectionFactory.setDatabase(database); return connectionFactory; } + @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); @@ -43,11 +58,23 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC } @Bean - public CacheManager apnsJwtCacheManager(RedisConnectionFactory cf) { + public RedisConnectionFactory redisCloudConnectionFactory() { + RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(redisCloudHost, redisCloudPort); + redisConfiguration.setUsername(redisCloudUsername); + redisConfiguration.setPassword(redisCloudPassword); + LettuceConnectionFactory apnsRedisConnectionFactory = new LettuceConnectionFactory(redisConfiguration); + apnsRedisConnectionFactory.setDatabase(redisCloudDatabase); + apnsRedisConnectionFactory.start(); + return apnsRedisConnectionFactory; + } + + @Bean + @Qualifier("redisCloudConnectionFactory") + public CacheManager apnsJwtCacheManager(RedisConnectionFactory redisCloudConnectionFactory) { RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .entryTtl(Duration.ofMinutes(30)); - return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build(); + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisCloudConnectionFactory).cacheDefaults(redisCacheConfiguration).build(); } } From 225db13b316574ee36961eedc70f07861d3621c8 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:24:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20#126=20=EB=A9=A4=EB=B2=84id=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20APNs=20payload=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/apns/dto/ApnsClientRequest.java | 4 ++-- .../config/apns/service/ApnsService.java | 4 +++- .../config/apns/service/ApnsServiceImpl.java | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java index 1af62a3c..eb804673 100644 --- a/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java +++ b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java @@ -12,8 +12,8 @@ public class ApnsClientRequest { public static class SimplePushBody { private Aps aps; - public SimplePushBody(String title, String subTitle, String body, Integer badge, String threaId, String targetContentId) { - this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, threaId, targetContentId); + public SimplePushBody(String title, String subTitle, String body, Integer badge, String threadId, String targetContentId) { + this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, threadId, targetContentId); } @ToString diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsService.java b/src/main/java/com/server/capple/config/apns/service/ApnsService.java index 4b262fa4..05032e3c 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsService.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsService.java @@ -3,6 +3,8 @@ import java.util.List; public interface ApnsService { - Boolean sendApns(T request, List deviceToken); + Boolean sendApns(T request, String ... deviceTokens); + Boolean sendApns(T request, List deviceTokenList); + Boolean sendApnsToMembers(T request, Long ... memberIds); Boolean sendApnsToMembers(T request, List memberIdList); } diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java index 6be5cefa..81acd50b 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java @@ -41,6 +41,11 @@ public void init() { .build(); } + @Override + public Boolean sendApns(T request, String... deviceToken) { + return sendApns(request, List.of(deviceToken)); + } + @Override public Boolean sendApns(T request, List deviceToken) { WebClient tmpWebClient = defaultApnsWebClient.mutate() @@ -59,6 +64,16 @@ public Boolean sendApns(T request, List deviceToken) { .bodyValue(request) .retrieve() .bodyToMono(Void.class) + .doOnDiscard(Void.class, response -> {// 거절 시 보조 채널로 재시도 + tmpSubWebClient + .method(HttpMethod.POST) + .uri(token) + .bodyValue(request) + .retrieve() + .bodyToMono(Void.class) + .subscribe(); + log.info("APNs 전송 거절 발생"); + }) .doOnError(e -> { // 에러 발생 시 보조 채널로 재시도 tmpSubWebClient .method(HttpMethod.POST) @@ -74,6 +89,11 @@ public Boolean sendApns(T request, List deviceToken) { return true; } + @Override + public Boolean sendApnsToMembers(T request, Long... memberIds) { + return sendApns(request, deviceTokenRedisRepository.getDeviceTokens(List.of(memberIds))); + } + @Override public Boolean sendApnsToMembers(T request, List memberIdList) { return sendApns(request, deviceTokenRedisRepository.getDeviceTokens(memberIdList));