feat: Refactor monolith to microservices architecture

This commit is contained in:
jade
2026-07-05 17:27:44 +09:00
parent 367a47516d
commit 10f3f0513d
200 changed files with 541 additions and 1789 deletions

34
axhub-common/build.gradle Normal file
View File

@@ -0,0 +1,34 @@
plugins {
id 'java-library'
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
api 'io.github.resilience4j:resilience4j-core:2.2.0'
api 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
api 'io.github.resilience4j:resilience4j-ratelimiter'
api 'io.github.resilience4j:resilience4j-retry:2.2.0'
api 'org.springframework.boot:spring-boot-starter-aop:3.3.0'
api 'org.springframework.boot:spring-boot-starter-jdbc'
api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
api 'com.h2database:h2'
api 'p6spy:p6spy:3.9.1'
api 'io.lettuce:lettuce-core:6.6.0.RELEASE'
api "org.mapstruct:mapstruct:1.5.5.Final"
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:1.4.0'
api 'org.springframework.kafka:spring-kafka:3.2.0'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -0,0 +1,13 @@
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class ErrorDetail {
private int code;
private String message;
}

View File

@@ -0,0 +1,13 @@
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class JsonRpcRequest {
private String jsonrpc;
private String method;
private Params params;
private String id;
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.Getter;
import lombok.Setter;
// 2. 응답 DTO
@Getter
@Setter
@JsonPropertyOrder({"jsonrpc", "result", "error", "id"})
public class JsonRpcResponse {
public String jsonrpc = "2.0";
public Object result;
public Object error;
public String id;
}

View File

@@ -0,0 +1,17 @@
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Params {
private String routingType;
private String name;
private String interfaceId;
private Map<String, Object> data;
private List<Map<String, Object>> spec;
}

View File

@@ -0,0 +1,52 @@
package io.shinhanlife.axhub.biz.mcp.gateway.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Tool(Agent)의 명세 및 라우팅 정보를 담고 있는 메타데이터 클래스
* Redis 레지스트리에 저장되며, Planner와 Router 간의 통신 객체(Plan)로 사용됩니다.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ToolMetadata {
// 1. Tool 기본 정보
private String toolName; // 툴 고유 명칭 (예: CustomerSearchTool)
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
private Map<String, Object> parametersSchema;
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
private Map<String, String> actionPrompts;
// 2-1. 도메인 부서 그룹명 (권한 관리에 사용)
private String domainGroup;
// 2-2. 툴 처리 엔드포인트 URI 경로 (예: /api/tool/customer-info)
private String endpoint;
// 2-3. Pod 실행 URL (독립적인 Microservice 라우팅용, 예: http://localhost:8082)
private String podUrl;
// 3. 연동 아키텍처 구분 (DIRECT / MCI_EAI)
private String integrationType; // 연동 타입: "DIRECT" 또는 "MCI_EAI"
// 4. 레거시(MCI/EAI) 연동 시 필수 정보 (integrationType이 "MCI_EAI"일 때 사용)
private String mciServiceId; // MCI/EAI 호출을 위한 서비스 ID (예: CRM_001, LICO_992)
// 5. 인프라 상태 정보 (DIRECT 연동 시 사용)
private Long lastHeartbeat; // Redis TTL 갱신용 마지막 하트비트 타임스탬프
// 6. 동적 서킷 브레이커 & 속도 제어 설정 (Registry 기반)
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.axhub.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 엔드포인트에 대해 CORS 허용
.allowedOrigins("http://localhost:3006") // Vue 앱의 오리진 허용 (필요 시 여러 오리진 추가: .allowedOrigins("http://localhost:3006", "https://example.com"))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.allowCredentials(true) // 쿠키/인증 정보 허용 (필요 시)
.maxAge(3600); // preflight 요청 캐시 시간 (초 단위)
}
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.axhub.common.config;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
return sessionFactory.getObject();
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.axhub.common.config;
import com.p6spy.engine.logging.Category;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
public class P6SpySqlFormatter implements MessageFormattingStrategy {
@Override
public String formatMessage(int connectionId, String now, long elapsed,
String category, String prepared, String sql, String url) {
if (sql == null || sql.isBlank()) return "";
if (Category.STATEMENT.getName().equals(category)) {
String prettySQL = sql
.replaceAll("(?i)\\bSELECT\\b", "\nSELECT")
.replaceAll("(?i)\\bFROM\\b", "\n FROM")
.replaceAll("(?i)\\bWHERE\\b", "\n WHERE")
.replaceAll("(?i)\\bAND\\b", "\n AND")
.replaceAll("(?i)\\bOR\\b", "\n OR")
.replaceAll("(?i)\\bINNER JOIN\\b", "\n INNER JOIN")
.replaceAll("(?i)\\bLEFT JOIN\\b", "\n LEFT JOIN")
.replaceAll("(?i)\\bORDER BY\\b", "\n ORDER BY")
.replaceAll("(?i)\\bGROUP BY\\b", "\n GROUP BY")
.replaceAll("(?i)\\bINSERT INTO\\b", "\nINSERT INTO")
.replaceAll("(?i)\\bVALUES\\b", "\n VALUES")
.replaceAll("(?i)\\bUPDATE\\b", "\nUPDATE")
.replaceAll("(?i)\\bSET\\b", "\n SET")
.replaceAll("(?i)\\bDELETE FROM\\b", "\nDELETE FROM");
return String.format("""
\n┌─────────────────────────────────────────
│ SQL [%dms]
│%s
└─────────────────────────────────────────
""", elapsed, prettySQL.indent(2).stripTrailing());
}
return "";
}
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.axhub.common.mcp.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
// 스프링이 캐시를 관리할 기본 저장소를 빈(Bean)으로 등록합니다.
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("eimsData"); // 아까 설정한 캐시 이름 등록
}
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.axhub.common.mcp.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class JacksonConfig {
// 1. JSON 변환기(ObjectMapper)를 스프링 Bean으로 등록
@Bean
@Primary
public ObjectMapper jsonMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
// 2. XML 변환기(XmlMapper)를 스프링 Bean으로 등록
@Bean
public XmlMapper xmlMapper() {
return new XmlMapper();
}
}

View File

@@ -0,0 +1,44 @@
package io.shinhanlife.axhub.common.mcp.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.beans.factory.annotation.Value;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class KafkaLocalConfig {
@Value("${spring.kafka.bootstrap-servers:localhost:9092}")
private String bootstrapServers;
// 1. 카프카 전송 공장(Factory) 세팅
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
// 가짜 로컬 주소 혹은 환경변수 세팅
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
// 데이터를 카프카로 보낼 때 문자열(String) 형태로 변환하겠다는 규칙
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 3초 만에 빠른 실패 처리 (로컬 무한 대기 방지)
configProps.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 3000);
// 재접속 주기를 10초로 설정 (콘솔 로그 도배 방지)
configProps.put(ProducerConfig.RECONNECT_BACKOFF_MAX_MS_CONFIG, 10000);
return new DefaultKafkaProducerFactory<>(configProps);
}
// 2. EaiEimsSender가 애타게 찾던 KafkaTemplate을 스프링 Bean으로 등록!
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.axhub.common.mcp.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Shinhan MCP Gateway API 명세서")
.version("v1.0")
.description("AI Agent와 신한라이프 내부망(EIMS/EAI)을 연결하는 Adapter Gateway API 문서입니다."))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8080").description("Adapter Pod (8080)"))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)"))
// 전역적으로 X-API-KEY 보안 설정을 Swagger UI에 추가합니다.
.addSecurityItem(new SecurityRequirement().addList("X-API-KEY"))
.components(new Components()
.addSecuritySchemes("X-API-KEY",
new SecurityScheme()
.name("X-API-KEY")
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.description("헤더에 API Key를 입력해주세요. (기본값: SHINHAN_MCP_SECRET_KEY_2026)")));
}
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.axhub.common.mcp.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import io.shinhanlife.axhub.common.mcp.security.ApiKeyInterceptor;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
// 1. 우리가 만든 인터셉터를 주입받습니다.
private final ApiKeyInterceptor apiKeyInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 2. 인터셉터 등록 및 검사할 URL 패턴 지정
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/rpc/**", "/mcp/api/v1/**") // /rpc/, /mcp/api/v1/ 로 시작하는 모든 API는 API Key 검사 수행!
.excludePathPatterns(
"/test/**", "/health", "/error", "/mcp/api/v1/admin/**",
"/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**" // Swagger UI 경로는 인증 제외
);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Swagger UI(8080)에서 Gateway(8081)로 API 호출 시 발생하는 CORS 에러 해결
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.axhub.common.mcp.exception;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.ErrorDetail;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice // 이 어노테이션이 전역 적용의 핵심입니다!
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<JsonRpcResponse> handleIllegalArgument(IllegalArgumentException e) {
log.warn(" [Gateway Bad Request] 잘못된 요청: {}", e.getMessage());
return buildErrorResponse(-32602, "Invalid params: " + e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<JsonRpcResponse> handleRuntime(RuntimeException e) {
log.error(" [Gateway Internal Error] 시스템 장애: {}", e.getMessage(), e);
return buildErrorResponse(-32603, "Internal error: " + e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<JsonRpcResponse> handleAllException(Exception e) {
log.error("💥 [Gateway Fatal Error] 치명적 오류 발생", e);
return buildErrorResponse(-32000, "Server error: 시스템 관리자에게 문의하세요.");
}
private ResponseEntity<JsonRpcResponse> buildErrorResponse(int code, String message) {
JsonRpcResponse response = new JsonRpcResponse();
response.setError(new ErrorDetail(code, message));
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,42 @@
package io.shinhanlife.axhub.common.mcp.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class MdcLoggingFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 클라이언트가 보낸 Trace ID가 있으면 쓰고, 없으면 새로 생성
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
// 간결하게 8자리 UUID만 사용
traceId = UUID.randomUUID().toString().substring(0, 8);
}
// 로깅 컨텍스트에 고유 ID 저장
MDC.put(MDC_KEY, traceId);
try {
// 이 요청이 처리되는 동안 찍히는 모든 log.info, log.error에 traceId가 자동으로 붙습니다.
filterChain.doFilter(request, response);
} finally {
// 메모리 누수 방지를 위해 요청이 끝나면 반드시 비워줍니다.
MDC.clear();
}
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.axhub.common.mcp.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiKeyInterceptor implements HandlerInterceptor {
// 1. 다중 테넌트 API Key 목록이 담긴 프로퍼티 객체를 주입받습니다.
private final SecurityProperties securityProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
Map<String, String> validApiKeys = securityProperties.getApiKeys();
// 2. 헤더로 넘어온 API Key가 우리가 발급한 키 목록(Map)에 존재하는지 확인합니다.
if (apiKey == null || !validApiKeys.containsKey(apiKey)) {
log.warn(" [보안 차단] 유효하지 않은 API Key 접근 시도 - IP: {}", request.getRemoteAddr());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid API Key");
return false; // 컨트롤러로 넘어가지 않음
}
// 3. 유효한 키라면, 해당 키와 맵핑된 Tenant ID(식별자)를 가져옵니다. (ex. mcp-client-1)
String tenantId = validApiKeys.get(apiKey);
// 4. 추출한 Tenant ID를 현재 스레드의 로깅 컨텍스트(MDC)에 저장합니다.
// 이렇게 하면 이 요청이 끝날 때까지 찍히는 모든 로그에 어떤 테넌트가 호출했는지 자동으로 기록됩니다.
MDC.put("tenantId", tenantId);
// 5. 필요시 컨트롤러 로직에서 사용할 수 있도록 Request 속성에도 담아줍니다.
request.setAttribute("tenantId", tenantId);
log.debug(" [보안 통과] API Key 인증 성공 - 접속 테넌트: {}", tenantId);
return true; // 인증 통과! 컨트롤러로 진행
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다.
MDC.remove("tenantId");
}
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.axhub.common.mcp.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* [다중 테넌트 설정 매핑 클래스]
* application-local.properties 파일에 정의된 mcp.security.api-keys.* 설정들을
* Map 자료구조로 자동 바인딩(주입) 받기 위한 설정 클래스입니다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "mcp.security")
public class SecurityProperties {
// API Key를 Key로, Tenant ID를 Value로 가지는 맵 (ex. SHINHAN_MCP_SECRET_KEY_2026 -> mcp-client-1)
private Map<String, String> apiKeys = new HashMap<>();
// Tenant ID를 Key로, 허용된 도메인 그룹 목록을 Value로 가지는 맵 (ex. mcp-client-1 -> [CUSTOMER, COMMON])
// 만약 "ALL" 이 포함되어 있다면 모든 도메인에 접근 허용
private Map<String, List<String>> tenantDomains = new HashMap<>();
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.axhub.common.session.converter;
import io.shinhanlife.axhub.common.session.dto.SessionDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacOutDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public abstract class ZtUsacConverter {
@Mapping(target = "loginDtm", ignore = true)
@Mapping(target = "isManager", ignore = true)
public abstract SessionDto toSessionDto(ZtUsacOutDto dto);
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.axhub.common.session.domain.model;
import io.shinhanlife.glow.db.dto.AuditInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ZtUsacModel extends AuditInfo {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.axhub.common.session.domain.repository;
import io.shinhanlife.glow.GlowMybatisMapper;
import io.shinhanlife.axhub.common.session.dto.ZtUsacInDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacOutDto;
@GlowMybatisMapper
public interface ZtUsacRepository {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.axhub.common.session.domain.service;
import io.shinhanlife.axhub.common.session.dto.ZtUsacInDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacOutDto;
public interface ZtUsacService {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,28 @@
package io.shinhanlife.axhub.common.session.domain.service.impl;
import io.shinhanlife.axhub.common.session.domain.repository.ZtUsacRepository;
import io.shinhanlife.axhub.common.session.domain.service.ZtUsacService;
import io.shinhanlife.axhub.common.session.dto.ZtUsacInDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacOutDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ZtUsacServiceImpl implements ZtUsacService {
private final ZtUsacRepository ztUsacRepository;
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
@Override
public ZtUsacOutDto selectZtUsac(ZtUsacInDto dto) {
ZtUsacOutDto result = ztUsacRepository.selectZtUsac(dto);
result.initLists();
return result;
}
}

View File

@@ -0,0 +1,62 @@
package io.shinhanlife.axhub.common.session.dto;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Builder
@AllArgsConstructor
public class SessionDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직번호 */
private String ognzNm;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
// 유틸성
private String loginDtm; // 로그인일시
private String isManager; // 관리자여부
public void setLoginDtm() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
this.loginDtm = LocalDateTime.now().format(formatter);
}
public void setIsManager(String isManager) {
// TODO 역할 필터링 후 관리자 여부 체크
this.isManager = "Y";
}
}

View File

@@ -0,0 +1,24 @@
package io.shinhanlife.axhub.common.session.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Builder
@AllArgsConstructor
public class ZtUsacInDto {
public ZtUsacInDto() {
setPuseYn("Y");
}
/* 인사번호 */
private String prafNo;
/* 사용여부 */
private String puseYn;
}

View File

@@ -0,0 +1,68 @@
package io.shinhanlife.axhub.common.session.dto;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ZtUsacOutDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직명 */
private String ognzNm;
/* 이메일주소 */
// @GlowSecureField(type = DataSecureType.DECRYPT_DB, direction = SafeDBType.COMM) TODO 방화벽 뚫리면 확인
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
/* 사용여부 */
private String puseYn;
private String roleNoStrList;
private String roleNmStrList;
private String tgtrPrafNoStrList;
private String tgtrOgnzNoStrList;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
public void initLists() {
this.roleNoList = convertStrToList(roleNoStrList);
this.roleNmList = convertStrToList(roleNmStrList);
this.tgtrPrafNoList = convertStrToList(tgtrPrafNoStrList);
this.tgtrOgnzNoList = convertStrToList(tgtrOgnzNoStrList);
}
private List<String> convertStrToList(String str) {
if (str == null || str.isEmpty()) {
return new ArrayList<>();
}
return Arrays.asList(str.split(","));
}
}

View File

@@ -0,0 +1,77 @@
package io.shinhanlife.axhub.common.session.presentation;
import io.micrometer.common.util.StringUtils;
import io.shinhanlife.glow.BaseResponse;
import io.shinhanlife.glow.BizException;
import io.shinhanlife.glow.GlowControllerId;
import io.shinhanlife.glow.ResponseUtil;
import io.shinhanlife.axhub.common.session.converter.ZtUsacConverter;
import io.shinhanlife.axhub.common.session.domain.service.ZtUsacService;
import io.shinhanlife.axhub.common.session.dto.SessionDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacInDto;
import io.shinhanlife.axhub.common.session.dto.ZtUsacOutDto;
import io.shinhanlife.axhub.common.session.presentation.io.SsoResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.javassist.NotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/sso")
public class SsoRestController {
private static final String NLS_LOGIN_URL = "";
private final ZtUsacService ztUsacService;
private final ZtUsacConverter ztUsacConverter;
/**
* sso 연동 전 임시 로그인
*
* @param request
* @param response
* @param session
* @param <T>
* @return
*/
@GlowControllerId("tempLogin")
@PostMapping("/tempLogin")
public <T> ResponseEntity<BaseResponse<SsoResponse>> tempLogin(HttpServletRequest request, HttpServletResponse response,
HttpSession session, @RequestBody SessionDto requestDto) {
try {
if (StringUtils.isEmpty(requestDto.getPrafNo())) {
throw new NotFoundException("SSO >> not found sso id");
}
// DB 유저 가져오기
ZtUsacOutDto ztUsacOutDto = ztUsacService.selectZtUsac(ZtUsacInDto.builder().prafNo(requestDto.getPrafNo()).puseYn("Y")
.build());
if (Objects.isNull(ztUsacOutDto) || StringUtils.isEmpty(ztUsacOutDto.getPrafNo())) {
throw new BizException("SSO >> not found UserInfo >> retCode:");
}
SessionDto sessionDto = ztUsacConverter.toSessionDto(ztUsacOutDto);
sessionDto.setLoginDtm(); // 로그인 시점 세팅
session.setAttribute("userInfo", sessionDto);
return ResponseUtil.ok(SsoResponse.builder().retCode("0").userInfo(sessionDto).build());
} catch (Exception e) {
log.error("로그인 실패", e);
session.invalidate();
}
return ResponseUtil.ok(SsoResponse.builder().redirectUrl(NLS_LOGIN_URL).build());
}
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.axhub.common.session.presentation.io;
import io.shinhanlife.axhub.common.session.dto.SessionDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SsoResponse {
private String retCode;
private SessionDto userInfo;
private String redirectUrl;
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.axhub.common.util;
import io.shinhanlife.axhub.common.session.dto.SessionDto;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public class SessionUtil {
private static final String SESSION_KEY = "userInfo";
private SessionUtil() {}
public static SessionDto getSession() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return null;
HttpSession session = attributes.getRequest().getSession(false);
if (session == null) return null;
return (SessionDto) session.getAttribute(SESSION_KEY);
}
public static String getPrafNo() {
SessionDto session = getSession();
return session != null ? session.getPrafNo() : null;
}
public static String getOgnzNo() {
SessionDto session = getSession();
return session != null ? session.getOgnzNo() : null;
}
}

View File

@@ -0,0 +1,29 @@
package io.shinhanlife.glow;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
//@Schema(description = "응답 에러 객체. 성공 케이스일 경우 null. 실제 에러가 발생할 경우에만 예외명, 예외 메시지 필드 세팅 예정.")
public class BaseException {
// @Schema(description = "Error 코드 Meta 참조 운영. (예) 20001, 50001 등", shinhanlife = "20001")
String code;
// @Schema(description = "메시지")
String message;
// @Schema(description = "예외명.", shinhanlife = "NullPointerException")
@Builder.Default
String exceptionName = "";
// @Schema(description = "예외상세", shinhanlife = "StackTrace")
@Builder.Default
String exceptionDetail = "";
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.glow;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@ToString
@Getter
@Builder
@AllArgsConstructor
public class BaseResponse<T> {
// @Schema(description = "응답 코드 HTTP STATUS 오류 - 실제 Header 는 200 으로 내려감", shinhanlife = "200")
private int code;
// @Schema(description = "응답 메시지. 응답 코드와 매핑된 메시지.", shinhanlife = "데이터 생성 성공")
private String message;
// @Schema(description = "응답 본문 데이터. 실제 비즈니스 데이터.")
private T data;
private BaseException error;
}

View File

@@ -0,0 +1,6 @@
package io.shinhanlife.glow;
public class BizException extends RuntimeException {
public BizException(String s) {
}
}

View File

@@ -0,0 +1,13 @@
package io.shinhanlife.glow;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GlowAppServiceId {
String value();
String description() default "";
}

View File

@@ -0,0 +1,5 @@
package io.shinhanlife.glow;
public @interface GlowControllerId {
String value();
}

View File

@@ -0,0 +1,9 @@
package io.shinhanlife.glow;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GlowIndexPaging {
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.glow;
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GlowLogTarget {
Target[] value() default {};
enum Target {
FILE, CONSOLE
}
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.glow;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope("prototype")
public class GlowLogger {
private Logger log = LoggerFactory.getLogger(GlowLogger.class);
public void debug(String message, Object... args) { log.debug(message, args); }
public void info(String message, Object... args) { log.info(message, args); }
public void warn(String message, Object... args) { log.warn(message, args); }
public void error(String message, Object... args) { log.error(message, args); }
public void error(String message, Throwable t) { log.error(message, t); }
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.glow;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.lang.annotation.*;
/**
* MyBatis Mapper 인터페이스를 나타내는 어노테이션
* MyBatis Mapper와 동일한 기능을 제공합니다.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapper
@Component
public @interface GlowMybatisMapper {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.glow;
public @interface GlowServiceGroupId {
String value();
String description();
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.glow;
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GlowTrgmField {
/**
* 필드 순서
*/
int order();
/**
* 필드 길이
*/
int length();
/**
* 필드 설명
*/
String description() default "";
}

View File

@@ -0,0 +1,68 @@
package io.shinhanlife.glow;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.session.RowBounds;
import java.io.Serial;
import java.io.Serializable;
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
@EqualsAndHashCode(callSuper = true)
public class PageInfo extends RowBounds implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 페이지번호 ( 입력값 )
*/
@GlowTrgmField(order = 1, length = 5, description = "페이지번호")
private int pageNo;
/**
* 페이지 데이터 건수 ( 열 건수, 입력값 )
*/
@GlowTrgmField(order = 2, length = 5, description = "페이지데이터건수")
private int pageDataCc;
/**
* 총페이지 수 ( 리턴값 )
*/
@GlowTrgmField(order = 3, length = 10, description = "총페이지수")
private int totaPageCn;
/**
* 총 페이지 데이터 건수 ( 리턴값 )
*/
@GlowTrgmField(order = 4, length = 10, description = "총페이지데이터건수")
private int totaPageDataCc;
public PageInfo(int pageNo, int pageDataCc) {
super(((pageNo <= 0 ? 1 : pageNo) - 1) * pageDataCc, pageDataCc);
this.pageNo = pageNo;
this.pageDataCc = pageDataCc;
}
public PageInfo() {
}
@JsonIgnore
@Override
public int getOffset() {
return super.getOffset();
}
@JsonIgnore
@Override
public int getLimit() {
return super.getLimit();
}
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.glow;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum ResponseCode {
/* ===================== 성공 ===================== */
SUCCESS(HttpStatus.OK, "정상 처리되었습니다."),
CREATED(HttpStatus.CREATED, "데이터 생성 성공"),
/* ===================== 클라이언트 오류 (4xx) ===================== */
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "유효하지 않은 파라미터입니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 HTTP 메서드입니다."),
CONFLICT(HttpStatus.CONFLICT, "이미 존재하는 데이터입니다."),
/* ===================== 서버 오류 (5xx) ===================== */
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스를 사용할 수 없습니다.");
private final HttpStatus status;
private final String message;
}

View File

@@ -0,0 +1,111 @@
package io.shinhanlife.glow;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public final class ResponseUtil {
private ResponseUtil() {
}
public static <T> ResponseEntity<BaseResponse<T>> ok() {
return ResponseEntity.ok(
BaseResponse.<T>builder()
.code(ResponseCode.SUCCESS.getStatus().value())
.message(ResponseCode.SUCCESS.getMessage())
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> ok(T data) {
return ResponseEntity.ok(
BaseResponse.<T>builder()
.code(ResponseCode.SUCCESS.getStatus().value())
.message(ResponseCode.SUCCESS.getMessage())
.data(data)
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> ok(T data, ResponseCode responseCode) {
return ResponseEntity.ok(
BaseResponse.<T>builder()
.code(responseCode.getStatus().value())
.message(responseCode.getMessage())
.data(data)
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> error(
HttpStatus status, String code, String message
) {
return ResponseEntity.status(status)
.body(
BaseResponse.<T>builder()
.code(status.value())
.error(
BaseException.builder()
.code(code)
.message(message)
.build()
)
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> error(
HttpStatus status, String code, String message, String exceptionName, String stackTrace
) {
return ResponseEntity.status(status)
.body(
BaseResponse.<T>builder()
.code(status.value())
.error(
BaseException.builder()
.code(code)
.message(message)
.exceptionName(exceptionName)
.exceptionDetail(stackTrace)
.build()
)
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> okError(
HttpStatus status, String code, String message
) {
return ResponseEntity.ok(
BaseResponse.<T>builder()
.code(status.value())
.error(
BaseException.builder()
.code(code)
.message(message)
.build()
)
.build()
);
}
public static <T> ResponseEntity<BaseResponse<T>> okError(
HttpStatus status, String code, String message, String exceptionName, String stackTrace
) {
return ResponseEntity.ok(
BaseResponse.<T>builder()
.code(status.value())
.error(
BaseException.builder()
.code(code)
.message(message)
.exceptionName(exceptionName)
.exceptionDetail(stackTrace)
.build()
)
.build()
);
}
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.glow.db.dto;
import java.util.Date;
public class AuditInfo {
private Date systRgiDt; // 시스템등록일시
private String systRgiPrafNo; // 시스템등록인사번호
private String systRgiOgnzNo; // 시스템등록조직번호
private String systRgiSystCd; // 시스템등록시스템코드
private String systRgiPrgrId; // 시스템등록프로그램ID
private Date systChgDt; // 시스템변경일시
private String systChgPrafNo; // 시스템변경인사번호
private String systChgOgnzNo; // 시스템변경조직번호
private String systChgSystCd; // 시스템변경시스템코드
private String systChgPrgrId; // 시스템변경프로그램ID
}