";
+ msgg += "인증 코드 :
";
+ msgg += code + "
";
+ msgg += "
";
+ message.setText(msgg, "utf-8", "html"); // 내용
+ message.setFrom(new InternetAddress("Spon-us_team", "Spon-us")); // 보내는 사람
+
+ return message;
+ }
+
+ public static String createEmailCode() {
+ StringBuilder code = new StringBuilder();
+
+ for (int i = 0; i < 6; i++) { // 인증 코드 6자리
+ int index = RANDOM.nextInt(3); // 0~2 까지 랜덤
+ switch (index) {
+ case 0:
+ code.append((char)((RANDOM.nextInt(26)) + 97));
+ // a~z (ex. 1+97=98 => (char)98 = 'b')
+ break;
+ case 1:
+ code.append((char)((RANDOM.nextInt(26)) + 65));
+ // A~Z
+ break;
+ default:
+ code.append((RANDOM.nextInt(10)));
+ // 0~9
+ break;
+ }
+ }
+ return code.toString();
+ }
+}
diff --git a/core/core-infra-email/src/main/resources/application-email.yml b/core/core-infra-email/src/main/resources/application-email.yml
new file mode 100644
index 00000000..996e7cbc
--- /dev/null
+++ b/core/core-infra-email/src/main/resources/application-email.yml
@@ -0,0 +1,14 @@
+mail:
+ smtp:
+ port: ${EMAIL_PORT}
+ auth: true
+ starttls:
+ required: true
+ enable: true
+ socketFactory:
+ class: javax.net.ssl.SSLSocketFactory
+ fallback: false
+
+AdminMail:
+ id: ${ADMIN_EMAIL_ID}
+ password: ${ADMIN_EMAIL_PASSWORD}
diff --git a/core/core-infra-firebase/build.gradle b/core/core-infra-firebase/build.gradle
new file mode 100644
index 00000000..b0b5337b
--- /dev/null
+++ b/core/core-infra-firebase/build.gradle
@@ -0,0 +1,13 @@
+dependencies {
+ implementation project(':core:core-domain');
+ implementation project(':core:core-infra-redis');
+
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+ // FCM
+ implementation 'com.google.firebase:firebase-admin:9.2.0'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+}
+
+bootJar { enabled = false }
+jar { enabled = true }
diff --git a/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java
new file mode 100644
index 00000000..7643e093
--- /dev/null
+++ b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java
@@ -0,0 +1,46 @@
+package com.sponus.coreinfrafirebase;
+
+import com.sponus.coredomain.domain.notification.Notification;
+
+import lombok.Builder;
+
+@Builder
+public record FcmMessage(
+ boolean validateOnly,
+ Message message
+) {
+ public static FcmMessage of(boolean validateOnly, Message message) {
+ return FcmMessage.builder()
+ .validateOnly(validateOnly)
+ .message(message)
+ .build();
+ }
+
+ @Builder
+ public record Message(
+ String token,
+ NotificationSummary notification
+ ) {
+ public static Message of(String token, NotificationSummary notificationSummary) {
+ return Message.builder()
+ .token(token)
+ .notification(notificationSummary)
+ .build();
+ }
+ }
+
+ @Builder
+ public record NotificationSummary(
+ String title,
+ String body,
+ String image
+ ) {
+ public static NotificationSummary from(Notification notification) {
+ return NotificationSummary.builder()
+ .title(notification.getTitle())
+ .body(notification.getBody())
+ .image(null)
+ .build();
+ }
+ }
+}
diff --git a/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java
new file mode 100644
index 00000000..4b4b6a58
--- /dev/null
+++ b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java
@@ -0,0 +1,126 @@
+package com.sponus.coreinfrafirebase;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.sponus.coredomain.domain.announcement.Announcement;
+import com.sponus.coredomain.domain.notification.Notification;
+import com.sponus.coredomain.domain.notification.repository.NotificationRepository;
+import com.sponus.coredomain.domain.organization.Organization;
+import com.sponus.coredomain.domain.propose.Propose;
+import com.sponus.coredomain.domain.report.Report;
+import com.sponus.coreinfraredis.util.RedisUtil;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FirebaseService {
+
+ @Value("${firebase.fcmUrl}")
+ private String fcmUrl;
+
+ @Value("${firebase.firebaseConfigPath}")
+ private String firebaseConfigPath;
+
+ @Value("${firebase.scope}")
+ private String scope;
+
+ private final ObjectMapper objectMapper;
+
+ private final NotificationRepository notificationRepository;
+
+ private final RedisUtil redisUtil;
+
+ public void sendMessageTo(Organization targetOrganization, String title, String body, Announcement announcement,
+ Propose propose, Report report) throws IOException {
+
+ String token = getFcmToken(targetOrganization.getEmail());
+
+ Notification notification = Notification.builder()
+ .title(title)
+ .body(body)
+ .build();
+
+ notification.setOrganization(targetOrganization);
+ notification.setAnnouncement(announcement);
+ notification.setPropose(propose);
+ notification.setReport(report);
+
+ String message = makeFcmMessage(token, notificationRepository.save(notification));
+
+ OkHttpClient client = new OkHttpClient();
+ RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8"));
+
+ Request request = new Request.Builder()
+ .url(fcmUrl)
+ .post(requestBody)
+ .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken())
+ .addHeader(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8")
+ .build();
+
+ log.info("Sending FCM request. URL: {}, Headers: {}, Body: {}", fcmUrl, request.headers(), message);
+
+ Response response = client.newCall(request)
+ .execute();
+
+ // Response error
+ String responseBodyString = response.body().string();
+ log.info("Notification ResponseBody : {} ", responseBodyString);
+ int codeIndex = responseBodyString.indexOf("\"code\":");
+ int messageIndex = responseBodyString.indexOf("\"message\":");
+
+ if (codeIndex != -1 && messageIndex != -1) {
+
+ String responseErrorCode = responseBodyString.substring(codeIndex + "\"code\":".length(),
+ responseBodyString.indexOf(',', codeIndex));
+ responseErrorCode = responseErrorCode.trim();
+
+ String responseErrorMessage = responseBodyString.substring(messageIndex + "\"message\":".length(),
+ responseBodyString.indexOf(',', messageIndex));
+ responseErrorMessage = responseErrorMessage.trim();
+
+ log.info("[*]Error Code: " + responseErrorCode);
+ log.info("[*]Error Message: " + responseErrorMessage);
+ } else {
+ // Response 정상
+ log.info("[*]Error Code or Message not found");
+ }
+ }
+
+ private String makeFcmMessage(String token, Notification notification) throws JsonProcessingException {
+ FcmMessage fcmMessage = FcmMessage.of(false,
+ FcmMessage.Message.of(token,
+ FcmMessage.NotificationSummary.from(notification)));
+
+ log.info("Notification : {}", fcmMessage.message().notification().toString());
+
+ return objectMapper.writeValueAsString(fcmMessage);
+ }
+
+ private String getAccessToken() throws IOException {
+ GoogleCredentials googleCredentials = GoogleCredentials.fromStream(new ClassPathResource(firebaseConfigPath)
+ .getInputStream()).createScoped(List.of(scope));
+ googleCredentials.refreshIfExpired();
+ return googleCredentials.getAccessToken().getTokenValue();
+ }
+
+ public String getFcmToken(String email) {
+ return (String)redisUtil.get(email + "_fcm_token");
+ }
+}
diff --git a/core/core-infra-firebase/src/main/resources/application-firebase.yml b/core/core-infra-firebase/src/main/resources/application-firebase.yml
new file mode 100644
index 00000000..b26e2b75
--- /dev/null
+++ b/core/core-infra-firebase/src/main/resources/application-firebase.yml
@@ -0,0 +1,4 @@
+firebase:
+ fcmUrl: ${FIREBASE_URL}
+ firebaseConfigPath: ${FIREBASE_CONFIG_PATH}
+ scope: https://www.googleapis.com/auth/cloud-platform
diff --git a/core/core-infra-redis/build.gradle b/core/core-infra-redis/build.gradle
new file mode 100644
index 00000000..0a1f49b4
--- /dev/null
+++ b/core/core-infra-redis/build.gradle
@@ -0,0 +1,6 @@
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+}
+
+bootJar { enabled = false }
+jar { enabled = true }
diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java
new file mode 100644
index 00000000..9c0286b8
--- /dev/null
+++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java
@@ -0,0 +1,9 @@
+package com.sponus.coreinfraredis;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+
+@Configuration
+@EnableRedisRepositories("com.sponus.coreinfraredis")
+public class CoreRedisConfig {
+}
diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java
new file mode 100644
index 00000000..59931911
--- /dev/null
+++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java
@@ -0,0 +1,33 @@
+package com.sponus.coreinfraredis.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;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port}")
+ private int redisPort;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ return new LettuceConnectionFactory(redisHost, redisPort);
+ }
+
+ @Bean
+ public RedisTemplate
redisTemplate() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory());
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new StringRedisSerializer());
+ return redisTemplate;
+ }
+}
diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java
new file mode 100644
index 00000000..4329966a
--- /dev/null
+++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java
@@ -0,0 +1,35 @@
+package com.sponus.coreinfraredis.entity;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+import org.springframework.data.redis.core.TimeToLive;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@RedisHash("announcementView")
+public class AnnouncementView {
+ @Id
+ private String announcementId;
+ @Builder.Default
+ private Set organizationIds = new HashSet<>();
+
+ @TimeToLive
+ @Builder.Default
+ private Long expiration = Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay())
+ .getSeconds();
+}
diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java
new file mode 100644
index 00000000..3538334d
--- /dev/null
+++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java
@@ -0,0 +1,9 @@
+package com.sponus.coreinfraredis.repository;
+
+import org.springframework.data.repository.CrudRepository;
+
+import com.sponus.coreinfraredis.entity.AnnouncementView;
+
+public interface AnnouncementViewRepository extends CrudRepository {
+
+}
diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java
new file mode 100644
index 00000000..f22c3d65
--- /dev/null
+++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java
@@ -0,0 +1,56 @@
+package com.sponus.coreinfraredis.util;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class RedisUtil {
+
+ private final RedisTemplate redisTemplate;
+
+ public void saveAsValue(String key, Object val, Long time, TimeUnit timeUnit) {
+ redisTemplate.opsForValue().set(key, val, time, timeUnit);
+ }
+
+ public void appendToRecentlyViewedAnnouncement(String key, String newValue) {
+ long RECENT_VIEWED_ANNOUNCEMENT_LIMIT = 20;
+
+ log.info("[*] Newly Viewed Announcement: " + newValue);
+ Object mostRecentlyViewedValue = redisTemplate.opsForList().index(key, 0);
+ if (Objects.equals(mostRecentlyViewedValue, newValue)) {
+ log.info("[*] Skip saving viewed history...");
+ return;
+ }
+ if (Objects.equals(redisTemplate.opsForList().size(key), RECENT_VIEWED_ANNOUNCEMENT_LIMIT)) {
+ log.info("[*] Recent Announcement Deque Full Capacity..");
+ log.info("[*] Del Top()");
+ redisTemplate.opsForList().rightPop(key);
+ }
+ redisTemplate.opsForList().leftPush(key, newValue);
+ }
+
+ public boolean hasKey(String key) {
+ return Boolean.TRUE.equals(redisTemplate.hasKey(key));
+ }
+
+ public Object get(String key) {
+ return redisTemplate.opsForValue().get(key);
+ }
+
+ public List