Skip to content

Commit

Permalink
Merge pull request #135 from Team-Capple/feat/#126/apnsSetting
Browse files Browse the repository at this point in the history
[FEAT] ์• ํ”Œ ํ‘ธ์‹œ ์•Œ๋ฆผ ๊ตฌํ˜„
  • Loading branch information
jaewonLeeKOR authored Aug 25, 2024
2 parents d084f35 + 225db13 commit f610335
Show file tree
Hide file tree
Showing 16 changed files with 600 additions and 12 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/server/capple/CappleApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,7 @@
@EnableFeignClients
@EnableConfigurationProperties
@EnableScheduling
@EnableCaching
public class CappleApplication {

public static void main(String[] args) {
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/com/server/capple/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
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;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
public class RedisConfig {

Expand All @@ -18,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<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
Expand All @@ -35,4 +56,25 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisC

return redisTemplate;
}

@Bean
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(redisCloudConnectionFactory).cacheDefaults(redisCacheConfiguration).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.server.capple.config.apns.config;

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))) // ์“ฐ๊ธฐ ํƒ€์ž„ ์•„์›ƒ
.responseTimeout(Duration.ofSeconds(10)) // ์‘๋‹ต ํƒ€์ž„ ์•„์›ƒ
.secure(sslSpec -> sslSpec.sslContext(apnsSslContext)); // SSL ํ™œ์„ฑํ™”
}
}
Original file line number Diff line number Diff line change
@@ -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 threadId, String targetContentId) {
this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, threadId, 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; // ์‹คํ–‰์‹œ ๋ณด์—ฌ์ค„ ์ด๋ฏธ์ง€ ํŒŒ์ผ, ๊ธฐ๋ณธ ์‹คํ–‰ ์ด๋ฏธ์ง€ ๋Œ€์‹  ์ž…๋ ฅํ•œ ์ด๋ฏธ์ง€ ๋˜๋Š” ์Šคํ† ๋ฆฌ๋ณด๋“œ๊ฐ€ ์ผœ์ง
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.server.capple.config.apns.service;

import java.util.List;

public interface ApnsService {
<T> Boolean sendApns(T request, String ... deviceTokens);
<T> Boolean sendApns(T request, List<String> deviceTokenList);
<T> Boolean sendApnsToMembers(T request, Long ... memberIds);
<T> Boolean sendApnsToMembers(T request, List<Long> memberIdList);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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;
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 final DeviceTokenRedisRepository deviceTokenRedisRepository;
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 <T> Boolean sendApns(T request, String... deviceToken) {
return sendApns(request, List.of(deviceToken));
}

@Override
public <T> Boolean sendApns(T request, List<String> 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(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)
.uri(token)
.bodyValue(request)
.retrieve()
.bodyToMono(Void.class)
.subscribe();
log.error("APNs ์ „์†ก ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e);
})
.subscribe();
});
return true;
}

@Override
public <T> Boolean sendApnsToMembers(T request, Long... memberIds) {
return sendApns(request, deviceTokenRedisRepository.getDeviceTokens(List.of(memberIds)));
}

@Override
public <T> Boolean sendApnsToMembers(T request, List<Long> memberIdList) {
return sendApns(request, deviceTokenRedisRepository.getDeviceTokens(memberIdList));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ public interface JwtService {
Boolean isExpired(String token);
Boolean checkJwt(String token);
MemberResponse.Tokens refreshTokens(Long memberId, Role role);
String createApnsJwt();
}
Loading

0 comments on commit f610335

Please sign in to comment.