diff --git a/build.gradle b/build.gradle index 84ea7b8..325ae85 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + // Monitoring & Observability implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'net.logstash.logback:logstash-logback-encoder:7.4' @@ -70,6 +73,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/project/Application.java b/src/main/java/com/project/Application.java index ab1c782..460b288 100644 --- a/src/main/java/com/project/Application.java +++ b/src/main/java/com/project/Application.java @@ -6,7 +6,7 @@ @SpringBootApplication public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } } diff --git a/src/main/java/com/project/controller/HomeController.java b/src/main/java/com/project/controller/HomeController.java index 504547b..90c0355 100644 --- a/src/main/java/com/project/controller/HomeController.java +++ b/src/main/java/com/project/controller/HomeController.java @@ -1,6 +1,7 @@ package com.project.controller; import java.util.Map; + import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -9,23 +10,23 @@ @RestController public class HomeController { - @Value("${spring.application.name:backend}") - private String applicationName; + @Value("${spring.application.name:backend}") + private String applicationName; - @Value("${app.version:1.0.0}") - private String version; + @Value("${app.version:1.0.0}") + private String version; - @GetMapping("/") - public ResponseEntity> home() { - return ResponseEntity.ok( - Map.of( - "status", - "ok", - "message", - applicationName + " is running", - "version", - version, - "docs", - "/swagger-ui.html")); - } + @GetMapping("/") + public ResponseEntity> home() { + return ResponseEntity.ok( + Map.of( + "status", + "ok", + "message", + applicationName + " is running", + "version", + version, + "docs", + "/swagger-ui.html")); + } } diff --git a/src/main/java/com/project/example/controller/ExampleController.java b/src/main/java/com/project/example/controller/ExampleController.java index 4c46e12..2966fc0 100644 --- a/src/main/java/com/project/example/controller/ExampleController.java +++ b/src/main/java/com/project/example/controller/ExampleController.java @@ -1,9 +1,5 @@ package com.project.example.controller; -import com.project.example.controller.dto.SaveExampleRequest; -import com.project.example.infra.entity.ExampleEntity; -import com.project.example.service.ExampleService; -import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -12,20 +8,26 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.project.example.controller.dto.SaveExampleRequest; +import com.project.example.infra.entity.ExampleEntity; +import com.project.example.service.ExampleService; + +import lombok.RequiredArgsConstructor; + @RestController @RequestMapping("/example") @RequiredArgsConstructor public class ExampleController { - private final ExampleService exampleService; + private final ExampleService exampleService; - @GetMapping("/{exampleId}") - public ResponseEntity find(@PathVariable Long exampleId) { - return ResponseEntity.ok(exampleService.find(exampleId)); - } + @GetMapping("/{exampleId}") + public ResponseEntity find(@PathVariable Long exampleId) { + return ResponseEntity.ok(exampleService.find(exampleId)); + } - @PostMapping - public void save(@RequestBody SaveExampleRequest request) { - exampleService.save(request); - } + @PostMapping + public void save(@RequestBody SaveExampleRequest request) { + exampleService.save(request); + } } diff --git a/src/main/java/com/project/example/infra/entity/ExampleEntity.java b/src/main/java/com/project/example/infra/entity/ExampleEntity.java index c38c6b7..7f6bbb2 100644 --- a/src/main/java/com/project/example/infra/entity/ExampleEntity.java +++ b/src/main/java/com/project/example/infra/entity/ExampleEntity.java @@ -1,10 +1,12 @@ package com.project.example.infra.entity; -import com.project.example.controller.dto.SaveExampleRequest; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.Table; + +import com.project.example.controller.dto.SaveExampleRequest; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; @@ -16,16 +18,16 @@ @AllArgsConstructor public class ExampleEntity { - @Id @GeneratedValue private Long exampleId; + @Id @GeneratedValue private Long exampleId; - private String exampleName; + private String exampleName; - private String exampleContent; + private String exampleContent; - public static ExampleEntity create(SaveExampleRequest request) { - return ExampleEntity.builder() - .exampleName(request.exampleName()) - .exampleContent(request.exampleContent()) - .build(); - } + public static ExampleEntity create(SaveExampleRequest request) { + return ExampleEntity.builder() + .exampleName(request.exampleName()) + .exampleContent(request.exampleContent()) + .build(); + } } diff --git a/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java b/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java index ccbecc7..36cfb05 100644 --- a/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java +++ b/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java @@ -1,6 +1,7 @@ package com.project.example.infra.repository; -import com.project.example.infra.entity.ExampleEntity; import org.springframework.data.jpa.repository.JpaRepository; +import com.project.example.infra.entity.ExampleEntity; + public interface ExampleJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/project/example/infra/repository/ExampleRepository.java b/src/main/java/com/project/example/infra/repository/ExampleRepository.java index c840434..9bc2fcb 100644 --- a/src/main/java/com/project/example/infra/repository/ExampleRepository.java +++ b/src/main/java/com/project/example/infra/repository/ExampleRepository.java @@ -4,7 +4,7 @@ public interface ExampleRepository { - ExampleEntity find(Long exampleId); + ExampleEntity find(Long exampleId); - void save(ExampleEntity exampleEntity); + void save(ExampleEntity exampleEntity); } diff --git a/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java b/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java index dcebc37..cad16a1 100644 --- a/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java +++ b/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java @@ -1,24 +1,26 @@ package com.project.example.infra.repository; +import org.springframework.stereotype.Repository; + import com.project.example.infra.entity.ExampleEntity; import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.ExampleErrorCode; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; + private final ExampleJpaRepository exampleJpaRepository; - public ExampleEntity find(Long exampleId) { - return exampleJpaRepository - .findById(exampleId) - .orElseThrow(() -> new ApplicationException(ExampleErrorCode.EXAMPLE_NOT_FOUND)); - } + public ExampleEntity find(Long exampleId) { + return exampleJpaRepository + .findById(exampleId) + .orElseThrow(() -> new ApplicationException(ExampleErrorCode.EXAMPLE_NOT_FOUND)); + } - public void save(ExampleEntity example) { - exampleJpaRepository.save(example); - } + public void save(ExampleEntity example) { + exampleJpaRepository.save(example); + } } diff --git a/src/main/java/com/project/example/service/ExampleService.java b/src/main/java/com/project/example/service/ExampleService.java index b621bd8..e16a063 100644 --- a/src/main/java/com/project/example/service/ExampleService.java +++ b/src/main/java/com/project/example/service/ExampleService.java @@ -1,26 +1,28 @@ package com.project.example.service; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.project.example.controller.dto.SaveExampleRequest; import com.project.example.infra.entity.ExampleEntity; import com.project.example.infra.repository.ExampleRepository; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class ExampleService { - private final ExampleRepository exampleRepository; + private final ExampleRepository exampleRepository; - @Transactional - public ExampleEntity find(Long exampleId) { - return exampleRepository.find(exampleId); - } + @Transactional + public ExampleEntity find(Long exampleId) { + return exampleRepository.find(exampleId); + } - @Transactional - public void save(SaveExampleRequest request) { - ExampleEntity exampleEntity = ExampleEntity.create(request); - exampleRepository.save(exampleEntity); - } + @Transactional + public void save(SaveExampleRequest request) { + ExampleEntity exampleEntity = ExampleEntity.create(request); + exampleRepository.save(exampleEntity); + } } diff --git a/src/main/java/com/project/global/config/KafkaConfig.java b/src/main/java/com/project/global/config/KafkaConfig.java new file mode 100644 index 0000000..babf028 --- /dev/null +++ b/src/main/java/com/project/global/config/KafkaConfig.java @@ -0,0 +1,25 @@ +package com.project.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; + +@Configuration +@EnableKafka +public class KafkaConfig { + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + + factory.setConsumerFactory(consumerFactory); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + + return factory; + } +} diff --git a/src/main/java/com/project/global/config/MetricsConfig.java b/src/main/java/com/project/global/config/MetricsConfig.java new file mode 100644 index 0000000..ff663c8 --- /dev/null +++ b/src/main/java/com/project/global/config/MetricsConfig.java @@ -0,0 +1,73 @@ +package com.project.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; + +@Configuration +public class MetricsConfig { + + @Bean + public Counter emailSuccessCounter(MeterRegistry registry) { + return Counter.builder("notification.sent.total") + .tag("channel", "EMAIL") + .tag("status", "SUCCESS") + .description("Total successful email notifications") + .register(registry); + } + + @Bean + public Counter emailFailCounter(MeterRegistry registry) { + return Counter.builder("notification.sent.total") + .tag("channel", "EMAIL") + .tag("status", "FAIL") + .description("Total failed email notifications") + .register(registry); + } + + @Bean + public Counter smsSuccessCounter(MeterRegistry registry) { + return Counter.builder("notification.sent.total") + .tag("channel", "SMS") + .tag("status", "SUCCESS") + .description("Total successful SMS notifications") + .register(registry); + } + + @Bean + public Counter smsFailCounter(MeterRegistry registry) { + return Counter.builder("notification.sent.total") + .tag("channel", "SMS") + .tag("status", "FAIL") + .description("Total failed SMS notifications") + .register(registry); + } + + @Bean + public Counter smsFallbackCounter(MeterRegistry registry) { + return Counter.builder("notification.sent.total") + .tag("channel", "SMS") + .tag("status", "FALLBACK") + .description("Total SMS fallback notifications") + .register(registry); + } + + @Bean + public Timer emailProcessingTimer(MeterRegistry registry) { + return Timer.builder("notification.processing.time") + .tag("channel", "EMAIL") + .description("Email notification processing time") + .register(registry); + } + + @Bean + public Timer smsProcessingTimer(MeterRegistry registry) { + return Timer.builder("notification.processing.time") + .tag("channel", "SMS") + .description("SMS notification processing time") + .register(registry); + } +} diff --git a/src/main/java/com/project/global/config/RedisConfig.java b/src/main/java/com/project/global/config/RedisConfig.java index 0224258..3dd7f07 100644 --- a/src/main/java/com/project/global/config/RedisConfig.java +++ b/src/main/java/com/project/global/config/RedisConfig.java @@ -10,14 +10,14 @@ @Configuration public class RedisConfig { - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); - return template; - } + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } } diff --git a/src/main/java/com/project/global/config/SwaggerConfig.java b/src/main/java/com/project/global/config/SwaggerConfig.java index 034eff7..4e4fec3 100644 --- a/src/main/java/com/project/global/config/SwaggerConfig.java +++ b/src/main/java/com/project/global/config/SwaggerConfig.java @@ -1,27 +1,28 @@ package com.project.global.config; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + @Configuration public class SwaggerConfig { - @Value("${project.name:Backend API}") - private String projectName; + @Value("${project.name:Backend API}") + private String projectName; - @Value("${project.version:1.0.0}") - private String projectVersion; + @Value("${project.version:1.0.0}") + private String projectVersion; - @Bean - public OpenAPI openApi() { - return new OpenAPI() - .info( - new Info() - .title(projectName) - .description(projectName + " Documentation") - .version(projectVersion)); - } + @Bean + public OpenAPI openApi() { + return new OpenAPI() + .info( + new Info() + .title(projectName) + .description(projectName + " Documentation") + .version(projectVersion)); + } } diff --git a/src/main/java/com/project/global/config/WebConfig.java b/src/main/java/com/project/global/config/WebConfig.java index 89f9371..9926970 100644 --- a/src/main/java/com/project/global/config/WebConfig.java +++ b/src/main/java/com/project/global/config/WebConfig.java @@ -8,16 +8,15 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - @Value("${cors.allowed-origins:http://localhost:3000}") - private String allowedOrigins; + @Value("${cors.allowed-origins:http://localhost:3000}") + private String allowedOrigins; - @Override - public void addCorsMappings(CorsRegistry registry) { - registry - .addMapping("/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true); - } + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } } diff --git a/src/main/java/com/project/global/exception/ApplicationException.java b/src/main/java/com/project/global/exception/ApplicationException.java index 38acf76..85c8d56 100644 --- a/src/main/java/com/project/global/exception/ApplicationException.java +++ b/src/main/java/com/project/global/exception/ApplicationException.java @@ -4,7 +4,7 @@ public class ApplicationException extends BaseException { - public ApplicationException(BaseErrorCode code) { - super(code); - } + public ApplicationException(BaseErrorCode code) { + super(code); + } } diff --git a/src/main/java/com/project/global/exception/BaseException.java b/src/main/java/com/project/global/exception/BaseException.java index 9f50515..152e51c 100644 --- a/src/main/java/com/project/global/exception/BaseException.java +++ b/src/main/java/com/project/global/exception/BaseException.java @@ -1,23 +1,24 @@ package com.project.global.exception; import com.project.global.exception.code.domain.BaseErrorCode; + import lombok.Getter; @Getter public abstract class BaseException extends RuntimeException { - private final BaseErrorCode code; + private final BaseErrorCode code; - protected BaseException(BaseErrorCode code) { - super(code.getMessage()); - this.code = code; - } + protected BaseException(BaseErrorCode code) { + super(code.getMessage()); + this.code = code; + } - public static T from(BaseErrorCode code, Class exceptionClass) { - try { - return exceptionClass.getConstructor(BaseErrorCode.class).newInstance(code); - } catch (Exception e) { - throw new RuntimeException("Could not create exception instance", e); + public static T from(BaseErrorCode code, Class exceptionClass) { + try { + return exceptionClass.getConstructor(BaseErrorCode.class).newInstance(code); + } catch (Exception e) { + throw new RuntimeException("Could not create exception instance", e); + } } - } } diff --git a/src/main/java/com/project/global/exception/ExceptionAdvice.java b/src/main/java/com/project/global/exception/ExceptionAdvice.java index 9b20037..a597529 100644 --- a/src/main/java/com/project/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/project/global/exception/ExceptionAdvice.java @@ -1,41 +1,46 @@ package com.project.global.exception; -import com.project.global.exception.code.domain.BaseErrorCode; -import com.project.global.exception.code.domain.GlobalErrorCode; import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import com.project.global.exception.code.domain.BaseErrorCode; +import com.project.global.exception.code.domain.GlobalErrorCode; + +import lombok.extern.slf4j.Slf4j; + @Slf4j @RestControllerAdvice public class ExceptionAdvice extends ResponseEntityExceptionHandler { - /** BaseException - 도메인 예외 (ex: ApplicationException) */ - @ExceptionHandler(BaseException.class) - public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { - BaseErrorCode code = e.getCode(); - log.error("[BaseException] {} - {}", code.name(), code.getMessage()); + /** BaseException - 도메인 예외 (ex: ApplicationException) */ + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { + BaseErrorCode code = e.getCode(); + log.error("[BaseException] {} - {}", code.name(), code.getMessage()); - ErrorResponse response = - new ErrorResponse(code.getHttpStatus().value(), code.getCustomCode(), code.getMessage()); + ErrorResponse response = + new ErrorResponse( + code.getHttpStatus().value(), code.getCustomCode(), code.getMessage()); - return ResponseEntity.status(code.getHttpStatus()).body(response); - } + return ResponseEntity.status(code.getHttpStatus()).body(response); + } - /** 그 외 모든 예외 */ - @ExceptionHandler(Exception.class) - public ResponseEntity handleUnhandledException(Exception e, WebRequest request) { - log.error("[Exception] Unhandled: {}", e.getMessage(), e); + /** 그 외 모든 예외 */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnhandledException(Exception e, WebRequest request) { + log.error("[Exception] Unhandled: {}", e.getMessage(), e); - GlobalErrorCode code = GlobalErrorCode.INTERNAL_SERVER_ERROR; + GlobalErrorCode code = GlobalErrorCode.INTERNAL_SERVER_ERROR; - ErrorResponse response = - new ErrorResponse(code.getHttpStatus().value(), code.getCustomCode(), code.getMessage()); + ErrorResponse response = + new ErrorResponse( + code.getHttpStatus().value(), code.getCustomCode(), code.getMessage()); - return ResponseEntity.status(code.getHttpStatus()).body(response); - } + return ResponseEntity.status(code.getHttpStatus()).body(response); + } } diff --git a/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java b/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java index 5d9c87e..8f6c833 100644 --- a/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/BaseErrorCode.java @@ -3,11 +3,11 @@ import org.springframework.http.HttpStatus; public interface BaseErrorCode { - HttpStatus getHttpStatus(); + HttpStatus getHttpStatus(); - String getCustomCode(); + String getCustomCode(); - String getMessage(); + String getMessage(); - String name(); + String name(); } diff --git a/src/main/java/com/project/global/exception/code/domain/ExampleErrorCode.java b/src/main/java/com/project/global/exception/code/domain/ExampleErrorCode.java index f4ed8b9..5f5aabb 100644 --- a/src/main/java/com/project/global/exception/code/domain/ExampleErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/ExampleErrorCode.java @@ -1,16 +1,16 @@ package com.project.global.exception.code.domain; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum ExampleErrorCode implements BaseErrorCode { - EXAMPLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), - ; + EXAMPLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"); - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index ba45210..aa0b71e 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -1,16 +1,16 @@ package com.project.global.exception.code.domain; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum GlobalErrorCode implements BaseErrorCode { - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GLOBAL_001", "서버 내부 오류가 발생했습니다"), - ; + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GLOBAL_001", "서버 내부 오류가 발생했습니다"); - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } diff --git a/src/main/java/com/project/global/exception/code/domain/NotificationErrorCode.java b/src/main/java/com/project/global/exception/code/domain/NotificationErrorCode.java new file mode 100644 index 0000000..3d01f2c --- /dev/null +++ b/src/main/java/com/project/global/exception/code/domain/NotificationErrorCode.java @@ -0,0 +1,24 @@ +package com.project.global.exception.code.domain; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorCode implements BaseErrorCode { + TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTI_001", "템플릿을 찾을 수 없습니다"), + TEMPLATE_VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTI_002", "활성화된 템플릿 버전을 찾을 수 없습니다"), + SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTI_003", "구독 정보를 찾을 수 없습니다"), + CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTI_004", "고객 정보를 찾을 수 없습니다"), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NOTI_005", "이메일 발송에 실패했습니다"), + SMS_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NOTI_006", "SMS 발송에 실패했습니다"), + INVALID_NOTIFICATION_EVENT(HttpStatus.BAD_REQUEST, "NOTI_007", "잘못된 알림 이벤트입니다"), + RECIPIENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NOTI_008", "수신자 정보를 찾을 수 없습니다"), + CUSTOMER_DELETED(HttpStatus.BAD_REQUEST, "NOTI_009", "삭제된 고객입니다"); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; +} diff --git a/src/main/java/com/project/global/exception/code/domain/SecurityErrorCode.java b/src/main/java/com/project/global/exception/code/domain/SecurityErrorCode.java new file mode 100644 index 0000000..d5b7832 --- /dev/null +++ b/src/main/java/com/project/global/exception/code/domain/SecurityErrorCode.java @@ -0,0 +1,17 @@ +package com.project.global.exception.code.domain; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SecurityErrorCode implements BaseErrorCode { + ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SEC_001", "암호화/복호화 처리 중 오류가 발생했습니다"), + AES_KEY_LENGTH_INCORRECT(HttpStatus.INTERNAL_SERVER_ERROR, "SEC_002", "AES 키는 32바이트여야 합니다"); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; +} diff --git a/src/main/java/com/project/global/util/AesUtil.java b/src/main/java/com/project/global/util/AesUtil.java new file mode 100644 index 0000000..d754ac1 --- /dev/null +++ b/src/main/java/com/project/global/util/AesUtil.java @@ -0,0 +1,91 @@ +package com.project.global.util; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.SecurityErrorCode; + +@Component +public class AesUtil { + + @Value("${ureca.secret-key}") + private String secretKey; + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + @PostConstruct + public void init() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 32) { + throw new ApplicationException(SecurityErrorCode.AES_KEY_LENGTH_INCORRECT); + } + } + + public String encrypt(String plainText) { + if (plainText == null) { + return null; + } + + try { + byte[] iv = new byte[16]; + SECURE_RANDOM.nextBytes(iv); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + SecretKeySpec keySpec = + new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + + return Base64.getEncoder().encodeToString(combined); + } catch (GeneralSecurityException e) { + throw new ApplicationException(SecurityErrorCode.ENCRYPTION_FAILED); + } + } + + public String decrypt(String cipherText) { + if (cipherText == null) { + return null; + } + + try { + byte[] decoded = Base64.getDecoder().decode(cipherText); + + byte[] iv = new byte[16]; + System.arraycopy(decoded, 0, iv, 0, 16); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + byte[] encrypted = new byte[decoded.length - 16]; + System.arraycopy(decoded, 16, encrypted, 0, encrypted.length); + + SecretKeySpec keySpec = + new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new ApplicationException(SecurityErrorCode.ENCRYPTION_FAILED); + } + } +} diff --git a/src/main/java/com/project/global/util/JsonMapConverter.java b/src/main/java/com/project/global/util/JsonMapConverter.java new file mode 100644 index 0000000..8b0ad22 --- /dev/null +++ b/src/main/java/com/project/global/util/JsonMapConverter.java @@ -0,0 +1,45 @@ +package com.project.global.util; + +import java.util.Map; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Converter +public class JsonMapConverter implements AttributeConverter, String> { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null) { + return null; + } + try { + return OBJECT_MAPPER.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + log.error("Failed to convert map to JSON string", e); + return null; + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return null; + } + try { + return OBJECT_MAPPER.readValue(dbData, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + log.error("Failed to convert JSON string to map", e); + return null; + } + } +} diff --git a/src/main/java/com/project/notification/consumer/NotificationConsumer.java b/src/main/java/com/project/notification/consumer/NotificationConsumer.java new file mode 100644 index 0000000..764d74b --- /dev/null +++ b/src/main/java/com/project/notification/consumer/NotificationConsumer.java @@ -0,0 +1,92 @@ +package com.project.notification.consumer; + +import java.util.Map; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.notification.dto.NotificationRequestEvent; +import com.project.notification.service.NotificationService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationConsumer { + + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + @Value("${notification.consumer.concurrency:1}") + private int concurrency; + + @KafkaListener( + id = "notification-consumer", + topics = "notification_topic", + groupId = "notification-consumer-group", + containerFactory = "kafkaListenerContainerFactory") + public void consume(ConsumerRecord record, Acknowledgment ack) { + log.info( + "Received notification message. topic: {}, partition: {}, offset: {}", + record.topic(), + record.partition(), + record.offset()); + + try { + Map rawPayload = + objectMapper.readValue(record.value(), new TypeReference<>() {}); + + NotificationRequestEvent event = parseEvent(rawPayload); + + if (event == null) { + log.error("Failed to parse notification event. value: {}", record.value()); + ack.acknowledge(); + return; + } + + notificationService.processNotification(event, rawPayload); + + } catch (JsonProcessingException e) { + log.error("Failed to deserialize message. value: {}", record.value(), e); + } catch (Exception e) { + log.error("Failed to process notification. value: {}", record.value(), e); + } finally { + ack.acknowledge(); + } + } + + @SuppressWarnings("unchecked") + private NotificationRequestEvent parseEvent(Map rawPayload) { + try { + String traceId = (String) rawPayload.get("traceId"); + Object subIdObj = rawPayload.get("subscriptionId"); + Long subscriptionId = + subIdObj instanceof Number ? ((Number) subIdObj).longValue() : null; + String templateCode = (String) rawPayload.get("templateCode"); + Map variables = (Map) rawPayload.get("variables"); + + if (traceId == null || subscriptionId == null || templateCode == null) { + log.error( + "Required fields missing. traceId: {}, subscriptionId: {}, templateCode:" + + " {}", + traceId, + subscriptionId, + templateCode); + return null; + } + + return new NotificationRequestEvent(traceId, subscriptionId, templateCode, variables); + } catch (Exception e) { + log.error("Failed to parse event from payload", e); + return null; + } + } +} diff --git a/src/main/java/com/project/notification/dto/NotificationRequestEvent.java b/src/main/java/com/project/notification/dto/NotificationRequestEvent.java new file mode 100644 index 0000000..dbf5864 --- /dev/null +++ b/src/main/java/com/project/notification/dto/NotificationRequestEvent.java @@ -0,0 +1,6 @@ +package com.project.notification.dto; + +import java.util.Map; + +public record NotificationRequestEvent( + String traceId, Long subscriptionId, String templateCode, Map variables) {} diff --git a/src/main/java/com/project/notification/dto/RenderedMessage.java b/src/main/java/com/project/notification/dto/RenderedMessage.java new file mode 100644 index 0000000..0dc6a83 --- /dev/null +++ b/src/main/java/com/project/notification/dto/RenderedMessage.java @@ -0,0 +1,6 @@ +package com.project.notification.dto; + +import com.project.notification.infra.entity.enums.Channel; + +public record RenderedMessage( + Channel channel, Long templateVersionId, String subject, String body, String recipient) {} diff --git a/src/main/java/com/project/notification/infra/entity/Customer.java b/src/main/java/com/project/notification/infra/entity/Customer.java new file mode 100644 index 0000000..7009137 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/Customer.java @@ -0,0 +1,53 @@ +package com.project.notification.infra.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import com.project.notification.infra.entity.enums.Grade; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-Only Customer entity for notification service. This is a copy from api-core, used only for + * reading customer information. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "customer") +public class Customer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "customer_id") + private Long customerId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "contact_enc", nullable = false) + private String contactEnc; + + @Column(name = "email_enc", nullable = false) + private String emailEnc; + + @Enumerated(EnumType.STRING) + @Column(name = "grade", nullable = false, length = 20) + private Grade grade; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted; +} diff --git a/src/main/java/com/project/notification/infra/entity/MessageLog.java b/src/main/java/com/project/notification/infra/entity/MessageLog.java new file mode 100644 index 0000000..40f8f0e --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/MessageLog.java @@ -0,0 +1,103 @@ +package com.project.notification.infra.entity; + +import java.time.LocalDateTime; +import java.util.Map; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import com.project.global.util.JsonMapConverter; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.MessageStatus; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "message_log", + uniqueConstraints = { + @UniqueConstraint( + name = "idx_message_log_trace", + columnNames = {"trace_id", "channel"}) + }, + indexes = { + @Index(name = "idx_message_log_sub", columnList = "sub_id"), + @Index(name = "idx_message_log_sent_at", columnList = "sent_at") + }) +public class MessageLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "trace_id", nullable = false, length = 36) + private String traceId; + + @Column(name = "sub_id", nullable = false) + private Long subId; + + @Column(name = "recipient_enc", length = 500) + private String recipientEnc; + + @Column(name = "template_version_id") + private Long templateVersionId; + + @Enumerated(EnumType.STRING) + @Column(name = "channel", nullable = false, length = 10) + private Channel channel; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private MessageStatus status; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Convert(converter = JsonMapConverter.class) + @Column(name = "request_payload", columnDefinition = "jsonb") + private Map requestPayload; + + @Column(name = "processing_time_ms") + private Long processingTimeMs; + + @Column(name = "sent_at", nullable = false) + private LocalDateTime sentAt; + + @Builder + public MessageLog( + String traceId, + Long subId, + String recipientEnc, + Long templateVersionId, + Channel channel, + MessageStatus status, + String errorMessage, + Map requestPayload, + Long processingTimeMs) { + this.traceId = traceId; + this.subId = subId; + this.recipientEnc = recipientEnc; + this.templateVersionId = templateVersionId; + this.channel = channel; + this.status = status; + this.errorMessage = errorMessage; + this.requestPayload = requestPayload; + this.processingTimeMs = processingTimeMs; + this.sentAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/project/notification/infra/entity/Subscription.java b/src/main/java/com/project/notification/infra/entity/Subscription.java new file mode 100644 index 0000000..c0f965e --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/Subscription.java @@ -0,0 +1,57 @@ +package com.project.notification.infra.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import com.project.notification.infra.entity.enums.SubscriptionStatus; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-Only Subscription entity for notification service. This is a copy from api-core, used only + * for reading subscription information. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "subscription") +public class Subscription { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sub_id") + private Long subId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_id", nullable = false) + private Customer customer; + + @Column(name = "phone_number", nullable = false) + private String phoneNumber; + + @Column(name = "start_date", nullable = false) + private LocalDateTime startDate; + + @Column(name = "end_date") + private LocalDateTime endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private SubscriptionStatus status; + + @Column(name = "send_day", nullable = false) + private Integer sendDay; +} diff --git a/src/main/java/com/project/notification/infra/entity/TemplateGroup.java b/src/main/java/com/project/notification/infra/entity/TemplateGroup.java new file mode 100644 index 0000000..d7869c6 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/TemplateGroup.java @@ -0,0 +1,47 @@ +package com.project.notification.infra.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "template_group") +public class TemplateGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "group_id") + private Long groupId; + + @Column(name = "group_code", nullable = false, unique = true, length = 50) + private String groupCode; + + @Column(name = "group_name", nullable = false, length = 100) + private String groupName; + + @Column(name = "description") + private String description; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Builder + public TemplateGroup(String groupCode, String groupName, String description) { + this.groupCode = groupCode; + this.groupName = groupName; + this.description = description; + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/project/notification/infra/entity/TemplateVersion.java b/src/main/java/com/project/notification/infra/entity/TemplateVersion.java new file mode 100644 index 0000000..4a887df --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/TemplateVersion.java @@ -0,0 +1,76 @@ +package com.project.notification.infra.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.TemplateStatus; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "template_version") +public class TemplateVersion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "version_id") + private Long versionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private TemplateGroup templateGroup; + + @Enumerated(EnumType.STRING) + @Column(name = "channel", nullable = false, length = 10) + private Channel channel; + + @Column(name = "subject", length = 200) + private String subject; + + @Column(name = "body", nullable = false, columnDefinition = "TEXT") + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private TemplateStatus status; + + @Column(name = "version", nullable = false) + private Integer version; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Builder + public TemplateVersion( + TemplateGroup templateGroup, + Channel channel, + String subject, + String body, + TemplateStatus status, + Integer version) { + this.templateGroup = templateGroup; + this.channel = channel; + this.subject = subject; + this.body = body; + this.status = status; + this.version = version; + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/project/notification/infra/entity/enums/Channel.java b/src/main/java/com/project/notification/infra/entity/enums/Channel.java new file mode 100644 index 0000000..77bc145 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/enums/Channel.java @@ -0,0 +1,6 @@ +package com.project.notification.infra.entity.enums; + +public enum Channel { + EMAIL, + SMS +} diff --git a/src/main/java/com/project/notification/infra/entity/enums/Grade.java b/src/main/java/com/project/notification/infra/entity/enums/Grade.java new file mode 100644 index 0000000..2a9d9d2 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/enums/Grade.java @@ -0,0 +1,9 @@ +package com.project.notification.infra.entity.enums; + +public enum Grade { + BRONZE, + SILVER, + GOLD, + PLATINUM, + VIP +} diff --git a/src/main/java/com/project/notification/infra/entity/enums/MessageStatus.java b/src/main/java/com/project/notification/infra/entity/enums/MessageStatus.java new file mode 100644 index 0000000..bc60a96 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/enums/MessageStatus.java @@ -0,0 +1,7 @@ +package com.project.notification.infra.entity.enums; + +public enum MessageStatus { + SUCCESS, + FAIL, + SUCCESS_FALLBACK +} diff --git a/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java b/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java new file mode 100644 index 0000000..bd25456 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java @@ -0,0 +1,6 @@ +package com.project.notification.infra.entity.enums; + +public enum SubscriptionStatus { + ACTIVE, + TERMINATED +} diff --git a/src/main/java/com/project/notification/infra/entity/enums/TemplateStatus.java b/src/main/java/com/project/notification/infra/entity/enums/TemplateStatus.java new file mode 100644 index 0000000..7b63398 --- /dev/null +++ b/src/main/java/com/project/notification/infra/entity/enums/TemplateStatus.java @@ -0,0 +1,6 @@ +package com.project.notification.infra.entity.enums; + +public enum TemplateStatus { + ACTIVE, + DRAFT +} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java b/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java new file mode 100644 index 0000000..e0c04fd --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java @@ -0,0 +1,7 @@ +package com.project.notification.infra.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.project.notification.infra.entity.Customer; + +public interface CustomerJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerRepository.java b/src/main/java/com/project/notification/infra/repository/CustomerRepository.java new file mode 100644 index 0000000..6b4207c --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/CustomerRepository.java @@ -0,0 +1,10 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import com.project.notification.infra.entity.Customer; + +public interface CustomerRepository { + + Optional findById(Long customerId); +} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java new file mode 100644 index 0000000..f61960e --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.project.notification.infra.entity.Customer; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CustomerRepositoryImpl implements CustomerRepository { + + private final CustomerJpaRepository customerJpaRepository; + + @Override + public Optional findById(Long customerId) { + return customerJpaRepository.findById(customerId); + } +} diff --git a/src/main/java/com/project/notification/infra/repository/MessageLogJpaRepository.java b/src/main/java/com/project/notification/infra/repository/MessageLogJpaRepository.java new file mode 100644 index 0000000..850ed66 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/MessageLogJpaRepository.java @@ -0,0 +1,11 @@ +package com.project.notification.infra.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.project.notification.infra.entity.MessageLog; +import com.project.notification.infra.entity.enums.Channel; + +public interface MessageLogJpaRepository extends JpaRepository { + + boolean existsByTraceIdAndChannel(String traceId, Channel channel); +} diff --git a/src/main/java/com/project/notification/infra/repository/MessageLogRepository.java b/src/main/java/com/project/notification/infra/repository/MessageLogRepository.java new file mode 100644 index 0000000..ff523cd --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/MessageLogRepository.java @@ -0,0 +1,11 @@ +package com.project.notification.infra.repository; + +import com.project.notification.infra.entity.MessageLog; +import com.project.notification.infra.entity.enums.Channel; + +public interface MessageLogRepository { + + boolean existsByTraceIdAndChannel(String traceId, Channel channel); + + void save(MessageLog messageLog); +} diff --git a/src/main/java/com/project/notification/infra/repository/MessageLogRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/MessageLogRepositoryImpl.java new file mode 100644 index 0000000..6a8e268 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/MessageLogRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.project.notification.infra.repository; + +import org.springframework.stereotype.Repository; + +import com.project.notification.infra.entity.MessageLog; +import com.project.notification.infra.entity.enums.Channel; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MessageLogRepositoryImpl implements MessageLogRepository { + + private final MessageLogJpaRepository messageLogJpaRepository; + + @Override + public boolean existsByTraceIdAndChannel(String traceId, Channel channel) { + return messageLogJpaRepository.existsByTraceIdAndChannel(traceId, channel); + } + + @Override + public void save(MessageLog messageLog) { + messageLogJpaRepository.save(messageLog); + } +} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java b/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java new file mode 100644 index 0000000..eea0464 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java @@ -0,0 +1,15 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.project.notification.infra.entity.Subscription; + +public interface SubscriptionJpaRepository extends JpaRepository { + + @Query("SELECT s FROM Subscription s JOIN FETCH s.customer WHERE s.subId = :subId") + Optional findByIdWithCustomer(@Param("subId") Long subId); +} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java b/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java new file mode 100644 index 0000000..97711e9 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java @@ -0,0 +1,10 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import com.project.notification.infra.entity.Subscription; + +public interface SubscriptionRepository { + + Optional findByIdWithCustomer(Long subId); +} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java new file mode 100644 index 0000000..37c6c21 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.project.notification.infra.entity.Subscription; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class SubscriptionRepositoryImpl implements SubscriptionRepository { + + private final SubscriptionJpaRepository subscriptionJpaRepository; + + @Override + public Optional findByIdWithCustomer(Long subId) { + return subscriptionJpaRepository.findByIdWithCustomer(subId); + } +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateGroupJpaRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateGroupJpaRepository.java new file mode 100644 index 0000000..bcceb43 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateGroupJpaRepository.java @@ -0,0 +1,12 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.project.notification.infra.entity.TemplateGroup; + +public interface TemplateGroupJpaRepository extends JpaRepository { + + Optional findByGroupCode(String groupCode); +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateGroupRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateGroupRepository.java new file mode 100644 index 0000000..b806acc --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateGroupRepository.java @@ -0,0 +1,10 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import com.project.notification.infra.entity.TemplateGroup; + +public interface TemplateGroupRepository { + + Optional findByGroupCode(String groupCode); +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateGroupRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/TemplateGroupRepositoryImpl.java new file mode 100644 index 0000000..8ce910f --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateGroupRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.project.notification.infra.entity.TemplateGroup; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class TemplateGroupRepositoryImpl implements TemplateGroupRepository { + + private final TemplateGroupJpaRepository templateGroupJpaRepository; + + @Override + public Optional findByGroupCode(String groupCode) { + return templateGroupJpaRepository.findByGroupCode(groupCode); + } +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java new file mode 100644 index 0000000..5ff89f1 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java @@ -0,0 +1,26 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.TemplateStatus; + +public interface TemplateVersionJpaRepository extends JpaRepository { + + @Query( + "SELECT tv FROM TemplateVersion tv " + + "WHERE tv.templateGroup.groupCode = :groupCode " + + "AND tv.channel = :channel " + + "AND tv.status = :status " + + "ORDER BY tv.version DESC " + + "LIMIT 1") + Optional findLatestByGroupCodeAndChannelAndStatus( + @Param("groupCode") String groupCode, + @Param("channel") Channel channel, + @Param("status") TemplateStatus status); +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java new file mode 100644 index 0000000..7d18036 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java @@ -0,0 +1,12 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; + +public interface TemplateVersionRepository { + + Optional findLatestActiveByGroupCodeAndChannel( + String groupCode, Channel channel); +} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java new file mode 100644 index 0000000..9a785f0 --- /dev/null +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.project.notification.infra.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.TemplateStatus; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class TemplateVersionRepositoryImpl implements TemplateVersionRepository { + + private final TemplateVersionJpaRepository templateVersionJpaRepository; + + @Override + public Optional findLatestActiveByGroupCodeAndChannel( + String groupCode, Channel channel) { + return templateVersionJpaRepository.findLatestByGroupCodeAndChannelAndStatus( + groupCode, channel, TemplateStatus.ACTIVE); + } +} diff --git a/src/main/java/com/project/notification/sender/EmailSender.java b/src/main/java/com/project/notification/sender/EmailSender.java new file mode 100644 index 0000000..00421e7 --- /dev/null +++ b/src/main/java/com/project/notification/sender/EmailSender.java @@ -0,0 +1,30 @@ +package com.project.notification.sender; + +import org.springframework.stereotype.Component; + +import com.project.notification.dto.RenderedMessage; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class EmailSender { + + public void send(RenderedMessage message) { + log.info( + "[MOCK EMAIL] To: {}, Subject: {}, Body: {}", + message.recipient(), + message.subject(), + truncateBody(message.body())); + } + + private String truncateBody(String body) { + if (body == null) { + return null; + } + if (body.length() > 100) { + return body.substring(0, 100) + "..."; + } + return body; + } +} diff --git a/src/main/java/com/project/notification/sender/SmsSender.java b/src/main/java/com/project/notification/sender/SmsSender.java new file mode 100644 index 0000000..98034e9 --- /dev/null +++ b/src/main/java/com/project/notification/sender/SmsSender.java @@ -0,0 +1,26 @@ +package com.project.notification.sender; + +import org.springframework.stereotype.Component; + +import com.project.notification.dto.RenderedMessage; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SmsSender { + + public void send(RenderedMessage message) { + log.info("[MOCK SMS] To: {}, Body: {}", message.recipient(), truncateBody(message.body())); + } + + private String truncateBody(String body) { + if (body == null) { + return null; + } + if (body.length() > 100) { + return body.substring(0, 100) + "..."; + } + return body; + } +} diff --git a/src/main/java/com/project/notification/service/NotificationService.java b/src/main/java/com/project/notification/service/NotificationService.java new file mode 100644 index 0000000..b25f5f6 --- /dev/null +++ b/src/main/java/com/project/notification/service/NotificationService.java @@ -0,0 +1,322 @@ +package com.project.notification.service; + +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.project.global.util.AesUtil; +import com.project.notification.dto.NotificationRequestEvent; +import com.project.notification.dto.RenderedMessage; +import com.project.notification.infra.entity.Customer; +import com.project.notification.infra.entity.MessageLog; +import com.project.notification.infra.entity.Subscription; +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.MessageStatus; +import com.project.notification.infra.repository.MessageLogRepository; +import com.project.notification.infra.repository.SubscriptionRepository; +import com.project.notification.sender.EmailSender; +import com.project.notification.sender.SmsSender; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Timer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final SubscriptionRepository subscriptionRepository; + private final MessageLogRepository messageLogRepository; + private final TemplateService templateService; + private final TemplateEngine templateEngine; + private final EmailSender emailSender; + private final SmsSender smsSender; + private final AesUtil aesUtil; + + private final Counter emailSuccessCounter; + private final Counter emailFailCounter; + private final Counter smsSuccessCounter; + private final Counter smsFailCounter; + private final Counter smsFallbackCounter; + private final Timer emailProcessingTimer; + private final Timer smsProcessingTimer; + + @Transactional + public void processNotification( + NotificationRequestEvent event, Map rawPayload) { + long startTime = System.currentTimeMillis(); + + if (isDuplicateRequest(event.traceId())) { + log.info("Duplicate request detected, skipping. traceId: {}", event.traceId()); + return; + } + + Optional subscriptionOpt = + subscriptionRepository.findByIdWithCustomer(event.subscriptionId()); + + if (subscriptionOpt.isEmpty()) { + log.error("Subscription not found. subId: {}", event.subscriptionId()); + return; + } + + Subscription subscription = subscriptionOpt.get(); + Customer customer = subscription.getCustomer(); + + if (Boolean.TRUE.equals(customer.getIsDeleted())) { + log.info( + "Customer is deleted, skipping notification. customerId: {}", + customer.getCustomerId()); + return; + } + + boolean emailSuccess = tryEmailSend(event, subscription, customer, rawPayload, startTime); + + if (!emailSuccess) { + trySmsFollback(event, subscription, customer, rawPayload); + } + } + + private boolean isDuplicateRequest(String traceId) { + return messageLogRepository.existsByTraceIdAndChannel(traceId, Channel.EMAIL) + || messageLogRepository.existsByTraceIdAndChannel(traceId, Channel.SMS); + } + + private boolean tryEmailSend( + NotificationRequestEvent event, + Subscription subscription, + Customer customer, + Map rawPayload, + long startTime) { + + long emailStartTime = System.currentTimeMillis(); + + String email = aesUtil.decrypt(customer.getEmailEnc()); + + if (email == null || email.isBlank()) { + log.warn("Email is empty for customer: {}", customer.getCustomerId()); + saveMessageLog( + event, + subscription.getSubId(), + null, + null, + Channel.EMAIL, + MessageStatus.FAIL, + "Email address is empty", + rawPayload, + System.currentTimeMillis() - startTime); + emailFailCounter.increment(); + return false; + } + + Optional templateOpt = + templateService.findActiveTemplate(event.templateCode(), Channel.EMAIL); + + if (templateOpt.isEmpty()) { + log.warn("Email template not found for code: {}", event.templateCode()); + saveMessageLog( + event, + subscription.getSubId(), + aesUtil.encrypt(email), + null, + Channel.EMAIL, + MessageStatus.FAIL, + "Email template not found", + rawPayload, + System.currentTimeMillis() - startTime); + emailFailCounter.increment(); + return false; + } + + TemplateVersion template = templateOpt.get(); + String subject = templateEngine.render(template.getSubject(), event.variables()); + String body = templateEngine.render(template.getBody(), event.variables()); + + RenderedMessage message = + new RenderedMessage(Channel.EMAIL, template.getVersionId(), subject, body, email); + + try { + emailSender.send(message); + + long processingTime = System.currentTimeMillis() - startTime; + emailProcessingTimer.record(processingTime, java.util.concurrent.TimeUnit.MILLISECONDS); + + saveMessageLog( + event, + subscription.getSubId(), + aesUtil.encrypt(email), + template.getVersionId(), + Channel.EMAIL, + MessageStatus.SUCCESS, + null, + rawPayload, + processingTime); + + emailSuccessCounter.increment(); + log.info( + "Email sent successfully. traceId: {}, recipient: {}", + event.traceId(), + maskEmail(email)); + return true; + + } catch (Exception e) { + log.error("Email send failed. traceId: {}", event.traceId(), e); + + saveMessageLog( + event, + subscription.getSubId(), + aesUtil.encrypt(email), + template.getVersionId(), + Channel.EMAIL, + MessageStatus.FAIL, + e.getMessage(), + rawPayload, + System.currentTimeMillis() - emailStartTime); + + emailFailCounter.increment(); + return false; + } + } + + private void trySmsFollback( + NotificationRequestEvent event, + Subscription subscription, + Customer customer, + Map rawPayload) { + + long smsStartTime = System.currentTimeMillis(); + + String phoneNumber = resolvePhoneNumber(subscription, customer); + + if (phoneNumber == null || phoneNumber.isBlank()) { + log.warn("Phone number is empty for subscription: {}", subscription.getSubId()); + saveMessageLog( + event, + subscription.getSubId(), + null, + null, + Channel.SMS, + MessageStatus.FAIL, + "Phone number is empty", + rawPayload, + System.currentTimeMillis() - smsStartTime); + smsFailCounter.increment(); + return; + } + + Optional templateOpt = + templateService.findActiveTemplate(event.templateCode(), Channel.SMS); + + if (templateOpt.isEmpty()) { + log.warn( + "SMS template not found for code: {}, fallback not possible", + event.templateCode()); + return; + } + + TemplateVersion template = templateOpt.get(); + String body = templateEngine.render(template.getBody(), event.variables()); + + RenderedMessage message = + new RenderedMessage(Channel.SMS, template.getVersionId(), null, body, phoneNumber); + + try { + smsSender.send(message); + + long processingTime = System.currentTimeMillis() - smsStartTime; + smsProcessingTimer.record(processingTime, java.util.concurrent.TimeUnit.MILLISECONDS); + + saveMessageLog( + event, + subscription.getSubId(), + aesUtil.encrypt(phoneNumber), + template.getVersionId(), + Channel.SMS, + MessageStatus.SUCCESS_FALLBACK, + null, + rawPayload, + processingTime); + + smsFallbackCounter.increment(); + log.info( + "SMS fallback sent successfully. traceId: {}, recipient: {}", + event.traceId(), + maskPhoneNumber(phoneNumber)); + + } catch (Exception e) { + log.error("SMS fallback failed. traceId: {}", event.traceId(), e); + + saveMessageLog( + event, + subscription.getSubId(), + aesUtil.encrypt(phoneNumber), + template.getVersionId(), + Channel.SMS, + MessageStatus.FAIL, + e.getMessage(), + rawPayload, + System.currentTimeMillis() - smsStartTime); + + smsFailCounter.increment(); + } + } + + private String resolvePhoneNumber(Subscription subscription, Customer customer) { + String subscriptionPhone = subscription.getPhoneNumber(); + if (subscriptionPhone != null && !subscriptionPhone.isBlank()) { + return subscriptionPhone; + } + + return aesUtil.decrypt(customer.getContactEnc()); + } + + private void saveMessageLog( + NotificationRequestEvent event, + Long subId, + String recipientEnc, + Long templateVersionId, + Channel channel, + MessageStatus status, + String errorMessage, + Map rawPayload, + Long processingTimeMs) { + + MessageLog log = + MessageLog.builder() + .traceId(event.traceId()) + .subId(subId) + .recipientEnc(recipientEnc) + .templateVersionId(templateVersionId) + .channel(channel) + .status(status) + .errorMessage(errorMessage) + .requestPayload(rawPayload) + .processingTimeMs(processingTimeMs) + .build(); + + messageLogRepository.save(log); + } + + private String maskEmail(String email) { + if (email == null || !email.contains("@")) { + return "***"; + } + int atIndex = email.indexOf("@"); + if (atIndex <= 2) { + return "***" + email.substring(atIndex); + } + return email.substring(0, 2) + "***" + email.substring(atIndex); + } + + private String maskPhoneNumber(String phone) { + if (phone == null || phone.length() < 4) { + return "***"; + } + return phone.substring(0, phone.length() - 4) + "****"; + } +} diff --git a/src/main/java/com/project/notification/service/TemplateEngine.java b/src/main/java/com/project/notification/service/TemplateEngine.java new file mode 100644 index 0000000..6d4f297 --- /dev/null +++ b/src/main/java/com/project/notification/service/TemplateEngine.java @@ -0,0 +1,47 @@ +package com.project.notification.service; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class TemplateEngine { + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)}}"); + + public String render(String template, Map variables) { + if (template == null) { + return null; + } + + if (variables == null || variables.isEmpty()) { + return template; + } + + StringBuffer result = new StringBuffer(); + Matcher matcher = VARIABLE_PATTERN.matcher(template); + + while (matcher.find()) { + String variableName = matcher.group(1); + String replacement = variables.get(variableName); + + if (replacement == null) { + log.warn( + "Template variable '{}' not found in variables map, replacing with empty" + + " string", + variableName); + replacement = ""; + } + + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + + matcher.appendTail(result); + return result.toString(); + } +} diff --git a/src/main/java/com/project/notification/service/TemplateService.java b/src/main/java/com/project/notification/service/TemplateService.java new file mode 100644 index 0000000..098a22b --- /dev/null +++ b/src/main/java/com/project/notification/service/TemplateService.java @@ -0,0 +1,37 @@ +package com.project.notification.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.repository.TemplateVersionRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TemplateService { + + private final TemplateVersionRepository templateVersionRepository; + + @Transactional(readOnly = true) + public Optional findActiveTemplate(String templateCode, Channel channel) { + Optional versionOpt = + templateVersionRepository.findLatestActiveByGroupCodeAndChannel( + templateCode, channel); + + if (versionOpt.isEmpty()) { + log.warn( + "Active template version not found for code: {}, channel: {}", + templateCode, + channel); + } + + return versionOpt; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 846c24f..adcadbc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,17 @@ cors: spring: application: - name: backend + name: api-message + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + consumer: + group-id: notification-consumer-group + auto-offset-reset: earliest + enable-auto-commit: false + max-poll-records: 1 + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer datasource: url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DATABASE:app} @@ -74,3 +84,10 @@ logging: root: INFO org.springframework.web: INFO org.hibernate.SQL: WARN + +ureca: + secret-key: ${AES_SECRET_KEY:12345678901234567890123456789012} + +notification: + consumer: + concurrency: ${CONSUMER_CONCURRENCY:1} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..859f9d8 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,77 @@ +-- Test data for Notification Worker +-- This script inserts sample data for testing notification functionality + +-- Template Groups +INSERT INTO template_group (id, code, name, description, created_at) +VALUES (1, 'USAGE_ALERT', 'Usage Alert', 'Data usage threshold alert notification', NOW()), + (2, 'PLAN_CHANGE', 'Plan Change', 'Plan change confirmation notification', NOW()), + (3, 'WELCOME', 'Welcome', 'Welcome message for new subscribers', NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Template Versions (EMAIL) +INSERT INTO template_version (id, group_id, channel, status, subject, body, created_at) +VALUES (1, 1, 'EMAIL', 'ACTIVE', + '[{{serviceName}}] Data Usage Alert', + 'Dear {{customerName}}, + +Your data usage has reached {{usagePercent}}% of your monthly allowance. + +Current Usage: {{currentUsage}} +Monthly Limit: {{monthlyLimit}} + +Please manage your data usage or consider upgrading your plan. + +Best regards, +{{serviceName}} Team', + NOW()), + (2, 2, 'EMAIL', 'ACTIVE', + '[{{serviceName}}] Plan Change Confirmation', + 'Dear {{customerName}}, + +Your plan has been successfully changed. + +Previous Plan: {{previousPlan}} +New Plan: {{newPlan}} +Effective Date: {{effectiveDate}} + +Thank you for using our service. + +Best regards, +{{serviceName}} Team', + NOW()), + (3, 3, 'EMAIL', 'ACTIVE', + 'Welcome to {{serviceName}}!', + 'Dear {{customerName}}, + +Welcome to {{serviceName}}! + +Your subscription has been activated. +Phone Number: {{phoneNumber}} +Plan: {{planName}} + +We hope you enjoy our service. + +Best regards, +{{serviceName}} Team', + NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Template Versions (SMS) +INSERT INTO template_version (id, group_id, channel, status, subject, body, created_at) +VALUES (4, 1, 'SMS', 'ACTIVE', + NULL, + '[{{serviceName}}] Data usage alert: {{usagePercent}}% used. Current: {{currentUsage}}, Limit: {{monthlyLimit}}', + NOW()), + (5, 2, 'SMS', 'ACTIVE', + NULL, + '[{{serviceName}}] Plan changed from {{previousPlan}} to {{newPlan}}. Effective: {{effectiveDate}}', + NOW()), + (6, 3, 'SMS', 'ACTIVE', + NULL, + '[{{serviceName}}] Welcome! Your subscription is now active. Plan: {{planName}}', + NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Reset sequences +SELECT setval('template_group_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM template_group), false); +SELECT setval('template_version_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM template_version), false); diff --git a/src/test/java/com/project/controller/HomeControllerTest.java b/src/test/java/com/project/controller/HomeControllerTest.java index ed53db9..e91bdbf 100644 --- a/src/test/java/com/project/controller/HomeControllerTest.java +++ b/src/test/java/com/project/controller/HomeControllerTest.java @@ -15,28 +15,28 @@ @WebMvcTest(HomeController.class) class HomeControllerTest { - @Autowired private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @Test - @WithMockUser - @DisplayName("GET / - API 기본 정보 반환") - void homeReturnsApiInfo() throws Exception { - mockMvc - .perform(get("/")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("ok")) - .andExpect(jsonPath("$.version").exists()) - .andExpect(jsonPath("$.docs").value("/swagger-ui.html")); - } + @Test + @WithMockUser + @DisplayName("GET / - API 기본 정보 반환") + void homeReturnsApiInfo() throws Exception { + mockMvc.perform(get("/")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ok")) + .andExpect(jsonPath("$.version").exists()) + .andExpect(jsonPath("$.docs").value("/swagger-ui.html")); + } - @Test - @WithMockUser - @DisplayName("GET / - message에 'running' 포함") - void homeMessageContainsRunning() throws Exception { - mockMvc - .perform(get("/")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("running"))); - } + @Test + @WithMockUser + @DisplayName("GET / - message에 'running' 포함") + void homeMessageContainsRunning() throws Exception { + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect( + jsonPath("$.message") + .value(org.hamcrest.Matchers.containsString("running"))); + } } diff --git a/src/test/java/com/project/example/controller/ExampleControllerTest.java b/src/test/java/com/project/example/controller/ExampleControllerTest.java index 35e9992..5a53cec 100644 --- a/src/test/java/com/project/example/controller/ExampleControllerTest.java +++ b/src/test/java/com/project/example/controller/ExampleControllerTest.java @@ -8,10 +8,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.project.example.controller.dto.SaveExampleRequest; -import com.project.example.infra.entity.ExampleEntity; -import com.project.example.service.ExampleService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -22,51 +18,54 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.example.controller.dto.SaveExampleRequest; +import com.project.example.infra.entity.ExampleEntity; +import com.project.example.service.ExampleService; + @WebMvcTest(ExampleController.class) class ExampleControllerTest { - @Autowired private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; + @Autowired private ObjectMapper objectMapper; - @MockitoBean private ExampleService exampleService; + @MockitoBean private ExampleService exampleService; - @Test - @WithMockUser - @DisplayName("GET /example/{id} - ExampleEntity 조회 성공") - void findReturnsOk() throws Exception { - // given - Long exampleId = 1L; - ExampleEntity entity = - ExampleEntity.builder().exampleName("test").exampleContent("content").build(); - given(exampleService.find(exampleId)).willReturn(entity); + @Test + @WithMockUser + @DisplayName("GET /example/{id} - ExampleEntity 조회 성공") + void findReturnsOk() throws Exception { + // given + Long exampleId = 1L; + ExampleEntity entity = + ExampleEntity.builder().exampleName("test").exampleContent("content").build(); + given(exampleService.find(exampleId)).willReturn(entity); - // when & then - mockMvc - .perform(get("/example/{exampleId}", exampleId)) - .andDo(print()) - .andExpect(status().isOk()); + // when & then + mockMvc.perform(get("/example/{exampleId}", exampleId)) + .andDo(print()) + .andExpect(status().isOk()); - verify(exampleService).find(exampleId); - } + verify(exampleService).find(exampleId); + } - @Test - @WithMockUser - @DisplayName("POST /example - ExampleEntity 저장 성공") - void saveReturnsOk() throws Exception { - // given - SaveExampleRequest request = new SaveExampleRequest("name", "content"); + @Test + @WithMockUser + @DisplayName("POST /example - ExampleEntity 저장 성공") + void saveReturnsOk() throws Exception { + // given + SaveExampleRequest request = new SaveExampleRequest("name", "content"); - // when & then - mockMvc - .perform( - post("/example") - .with(SecurityMockMvcRequestPostProcessors.csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()); + // when & then + mockMvc.perform( + post("/example") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()); - verify(exampleService).save(any(SaveExampleRequest.class)); - } + verify(exampleService).save(any(SaveExampleRequest.class)); + } } diff --git a/src/test/java/com/project/example/infra/repository/ExampleRepositoryImplTest.java b/src/test/java/com/project/example/infra/repository/ExampleRepositoryImplTest.java index a0cb9bb..f8c4748 100644 --- a/src/test/java/com/project/example/infra/repository/ExampleRepositoryImplTest.java +++ b/src/test/java/com/project/example/infra/repository/ExampleRepositoryImplTest.java @@ -6,9 +6,8 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; -import com.project.example.infra.entity.ExampleEntity; -import com.project.global.exception.ApplicationException; import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,52 +15,55 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.project.example.infra.entity.ExampleEntity; +import com.project.global.exception.ApplicationException; + @ExtendWith(MockitoExtension.class) class ExampleRepositoryImplTest { - @Mock private ExampleJpaRepository exampleJpaRepository; + @Mock private ExampleJpaRepository exampleJpaRepository; - @InjectMocks private ExampleRepositoryImpl exampleRepository; + @InjectMocks private ExampleRepositoryImpl exampleRepository; - @Test - @DisplayName("find - 존재하는 ID로 조회 시 ExampleEntity 반환") - void findWhenExistsReturnsEntity() { - // given - Long exampleId = 1L; - ExampleEntity expected = - ExampleEntity.builder().exampleName("test").exampleContent("content").build(); - given(exampleJpaRepository.findById(exampleId)).willReturn(Optional.of(expected)); + @Test + @DisplayName("find - 존재하는 ID로 조회 시 ExampleEntity 반환") + void findWhenExistsReturnsEntity() { + // given + Long exampleId = 1L; + ExampleEntity expected = + ExampleEntity.builder().exampleName("test").exampleContent("content").build(); + given(exampleJpaRepository.findById(exampleId)).willReturn(Optional.of(expected)); - // when - ExampleEntity result = exampleRepository.find(exampleId); + // when + ExampleEntity result = exampleRepository.find(exampleId); - // then - assertThat(result).isEqualTo(expected); - } + // then + assertThat(result).isEqualTo(expected); + } - @Test - @DisplayName("find - 존재하지 않는 ID로 조회 시 ApplicationException 발생") - void findWhenNotExistsThrowsException() { - // given - Long exampleId = 999L; - given(exampleJpaRepository.findById(exampleId)).willReturn(Optional.empty()); + @Test + @DisplayName("find - 존재하지 않는 ID로 조회 시 ApplicationException 발생") + void findWhenNotExistsThrowsException() { + // given + Long exampleId = 999L; + given(exampleJpaRepository.findById(exampleId)).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> exampleRepository.find(exampleId)) - .isInstanceOf(ApplicationException.class); - } + // when & then + assertThatThrownBy(() -> exampleRepository.find(exampleId)) + .isInstanceOf(ApplicationException.class); + } - @Test - @DisplayName("save - ExampleEntity 저장 성공") - void saveSavesEntity() { - // given - ExampleEntity entity = - ExampleEntity.builder().exampleName("test").exampleContent("content").build(); + @Test + @DisplayName("save - ExampleEntity 저장 성공") + void saveSavesEntity() { + // given + ExampleEntity entity = + ExampleEntity.builder().exampleName("test").exampleContent("content").build(); - // when - exampleRepository.save(entity); + // when + exampleRepository.save(entity); - // then - verify(exampleJpaRepository).save(any(ExampleEntity.class)); - } + // then + verify(exampleJpaRepository).save(any(ExampleEntity.class)); + } } diff --git a/src/test/java/com/project/example/service/ExampleServiceTest.java b/src/test/java/com/project/example/service/ExampleServiceTest.java index 5e586d5..a22868e 100644 --- a/src/test/java/com/project/example/service/ExampleServiceTest.java +++ b/src/test/java/com/project/example/service/ExampleServiceTest.java @@ -5,9 +5,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; -import com.project.example.controller.dto.SaveExampleRequest; -import com.project.example.infra.entity.ExampleEntity; -import com.project.example.infra.repository.ExampleRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,40 +12,44 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.project.example.controller.dto.SaveExampleRequest; +import com.project.example.infra.entity.ExampleEntity; +import com.project.example.infra.repository.ExampleRepository; + @ExtendWith(MockitoExtension.class) class ExampleServiceTest { - @Mock private ExampleRepository exampleRepository; + @Mock private ExampleRepository exampleRepository; - @InjectMocks private ExampleService exampleService; + @InjectMocks private ExampleService exampleService; - @Test - @DisplayName("find - ExampleEntity 조회 성공") - void findReturnsExampleEntity() { - // given - Long exampleId = 1L; - ExampleEntity expected = - ExampleEntity.builder().exampleName("test").exampleContent("content").build(); - given(exampleRepository.find(exampleId)).willReturn(expected); + @Test + @DisplayName("find - ExampleEntity 조회 성공") + void findReturnsExampleEntity() { + // given + Long exampleId = 1L; + ExampleEntity expected = + ExampleEntity.builder().exampleName("test").exampleContent("content").build(); + given(exampleRepository.find(exampleId)).willReturn(expected); - // when - ExampleEntity result = exampleService.find(exampleId); + // when + ExampleEntity result = exampleService.find(exampleId); - // then - assertThat(result).isEqualTo(expected); - verify(exampleRepository).find(exampleId); - } + // then + assertThat(result).isEqualTo(expected); + verify(exampleRepository).find(exampleId); + } - @Test - @DisplayName("save - ExampleEntity 저장 성공") - void saveSavesExampleEntity() { - // given - SaveExampleRequest request = new SaveExampleRequest("name", "content"); + @Test + @DisplayName("save - ExampleEntity 저장 성공") + void saveSavesExampleEntity() { + // given + SaveExampleRequest request = new SaveExampleRequest("name", "content"); - // when - exampleService.save(request); + // when + exampleService.save(request); - // then - verify(exampleRepository).save(any(ExampleEntity.class)); - } + // then + verify(exampleRepository).save(any(ExampleEntity.class)); + } }