diff --git a/docker/mysql/init/01-init.sql b/docker/mysql/init/01-init.sql index dd8e6051..1326a6de 100644 --- a/docker/mysql/init/01-init.sql +++ b/docker/mysql/init/01-init.sql @@ -36,4 +36,72 @@ ON DUPLICATE KEY UPDATE -- 참고: MySQL은 역순 정렬 최적화 시 DESC 인덱스가 도움될 수 있음 -- CREATE INDEX idx_audit_logs_rt_rid_et_ts_desc ON audit_logs(resource_type, resource_id, event_type, event_timestamp DESC); -- 대안(버전 호환): --- CREATE INDEX idx_audit_logs_rt_rid_et_ts ON audit_logs(resource_type, resource_id, event_type, event_timestamp); \ No newline at end of file +-- CREATE INDEX idx_audit_logs_rt_rid_et_ts ON audit_logs(resource_type, resource_id, event_type, event_timestamp); + +-- 테넌트 설정 관련 테이블 생성 +CREATE TABLE IF NOT EXISTS tenant_configs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + config_key VARCHAR(255) NOT NULL, + config_value TEXT, + config_type ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON', 'ENCRYPTED') NOT NULL, + description TEXT, + is_encrypted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + is_deleted BOOLEAN DEFAULT FALSE, + UNIQUE KEY uk_tenant_config (tenant_id, config_key), + INDEX idx_tenant_config_tenant_id (tenant_id), + INDEX idx_tenant_config_key (config_key), + INDEX idx_tenant_config_deleted (is_deleted) +); + +CREATE TABLE IF NOT EXISTS tenant_type_configs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_type ENUM('INDIVIDUAL', 'SMALL_BUSINESS', 'ENTERPRISE', 'GOVERNMENT') NOT NULL, + config_key VARCHAR(255) NOT NULL, + config_value TEXT, + config_type ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON', 'ENCRYPTED') NOT NULL, + description TEXT, + is_encrypted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + is_deleted BOOLEAN DEFAULT FALSE, + UNIQUE KEY uk_tenant_type_config (tenant_type, config_key), + INDEX idx_tenant_type_config_type (tenant_type), + INDEX idx_tenant_type_config_key (config_key), + INDEX idx_tenant_type_config_deleted (is_deleted) +); + +-- 테넌트 타입별 기본 설정 데이터 삽입 +INSERT INTO tenant_type_configs (tenant_type, config_key, config_value, config_type, description, is_encrypted) VALUES +-- ENTERPRISE 기본 설정 +('ENTERPRISE', 'max_users', '1000', 'NUMBER', 'Maximum number of users for Enterprise tenants', FALSE), +('ENTERPRISE', 'max_storage_gb', '10000', 'NUMBER', 'Maximum storage quota in GB for Enterprise tenants', FALSE), +('ENTERPRISE', 'support_level', 'premium', 'STRING', 'Support level for Enterprise tenants', FALSE), +('ENTERPRISE', 'max_file_size', '1024', 'NUMBER', 'Maximum file size in MB for Enterprise tenants', FALSE), + +-- SMALL_BUSINESS 기본 설정 +('SMALL_BUSINESS', 'max_users', '100', 'NUMBER', 'Maximum number of users for Small Business tenants', FALSE), +('SMALL_BUSINESS', 'max_storage_gb', '1000', 'NUMBER', 'Maximum storage quota in GB for Small Business tenants', FALSE), +('SMALL_BUSINESS', 'support_level', 'standard', 'STRING', 'Support level for Small Business tenants', FALSE), +('SMALL_BUSINESS', 'max_file_size', '100', 'NUMBER', 'Maximum file size in MB for Small Business tenants', FALSE), + +-- INDIVIDUAL 기본 설정 +('INDIVIDUAL', 'max_users', '10', 'NUMBER', 'Maximum number of users for Individual tenants', FALSE), +('INDIVIDUAL', 'max_storage_gb', '100', 'NUMBER', 'Maximum storage quota in GB for Individual tenants', FALSE), +('INDIVIDUAL', 'support_level', 'basic', 'STRING', 'Support level for Individual tenants', FALSE), +('INDIVIDUAL', 'max_file_size', '50', 'NUMBER', 'Maximum file size in MB for Individual tenants', FALSE), + +-- GOVERNMENT 기본 설정 +('GOVERNMENT', 'max_users', '5000', 'NUMBER', 'Maximum number of users for Government tenants', FALSE), +('GOVERNMENT', 'max_storage_gb', '50000', 'NUMBER', 'Maximum storage quota in GB for Government tenants', FALSE), +('GOVERNMENT', 'support_level', 'government', 'STRING', 'Support level for Government tenants', FALSE), +('GOVERNMENT', 'max_file_size', '2048', 'NUMBER', 'Maximum file size in MB for Government tenants', FALSE) +ON DUPLICATE KEY UPDATE + config_value = VALUES(config_value), + description = VALUES(description); \ No newline at end of file diff --git a/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md b/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md new file mode 100644 index 00000000..eeb7b57c --- /dev/null +++ b/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md @@ -0,0 +1,194 @@ +# 테넌트별 설정 상속 시스템 가이드 + +## 개요 + +테넌트별 설정 상속 시스템은 플랫폼 전역 설정, 테넌트 타입별 기본 설정, 개별 테넌트 설정 간의 상속 구조를 지원하여 모든 테넌트의 유효 설정을 일관성 있게 계산하고 캐시하는 시스템입니다. + +## 상속 순서 + +설정값은 다음 순서로 상속됩니다: + +1. **플랫폼 전역 설정** (최하위 우선순위) +2. **테넌트 타입별 기본 설정** (중간 우선순위) +3. **개별 테넌트 설정** (최상위 우선순위) + +## 주요 컴포넌트 + +### 1. 엔티티 + +- `TenantConfig`: 개별 테넌트의 설정 +- `TenantTypeConfig`: 테넌트 타입별 기본 설정 +- `PlatformConfig`: 플랫폼 전역 설정 (기존) + +### 2. 서비스 + +- `TenantConfigInheritanceService`: 설정 상속 로직 처리 +- `TenantConfigService`: 개별 테넌트 설정 관리 +- `TenantConfigCacheService`: 캐시 관리 + +### 3. 컨트롤러 + +- `TenantConfigController`: 테넌트 설정 API +- `TenantTypeConfigController`: 테넌트 타입별 설정 API + +## API 사용 예시 + +### 1. 테넌트의 유효 설정 조회 + +```http +GET /api/v1/tenants/{tenantKey}/configs/effective +``` + +**응답 예시:** + +```json +{ + "success": true, + "data": { + "tenantKey": "enterprise-tenant-1", + "configurations": { + "max_users": { + "value": 1000, + "source": "TENANT_TYPE", + "description": "Maximum number of users for Enterprise tenants", + "configType": "NUMBER" + }, + "max_file_size": { + "value": 2048, + "source": "TENANT", + "description": "Custom file size limit", + "configType": "NUMBER" + }, + "support_level": { + "value": "premium", + "source": "TENANT_TYPE", + "description": "Support level for Enterprise tenants", + "configType": "STRING" + } + } + } +} +``` + +### 2. 특정 설정 조회 + +```http +GET /api/v1/tenants/{tenantKey}/configs/effective/{configKey} +``` + +### 3. 테넌트 개별 설정 관리 + +```http +# 설정 생성/수정 +POST /api/v1/tenants/{tenantKey}/configs +Content-Type: application/json + +{ + "configKey": "custom_feature_enabled", + "configValue": "true", + "configType": "BOOLEAN", + "description": "Enable custom feature for this tenant" +} + +# 설정 삭제 +DELETE /api/v1/tenants/{tenantKey}/configs/{configKey} +``` + +### 4. 테넌트 타입별 기본 설정 관리 + +```http +# 타입별 설정 생성/수정 +POST /api/v1/tenant-types/{tenantType}/configs +Content-Type: application/json + +{ + "configKey": "max_users", + "configValue": "1000", + "configType": "NUMBER", + "description": "Maximum number of users for Enterprise tenants" +} +``` + +## 시나리오 예시 + +### 시나리오 1: 플랫폼 설정 상속 + +**Given**: 플랫폼 전역 설정에 `max_file_size: 100MB`가 설정됨 +**When**: 테넌트가 특별한 설정을 하지 않음 +**Then**: 테넌트는 자동으로 100MB 제한을 상속받음 + +### 시나리오 2: 테넌트별 설정 오버라이드 + +**Given**: 플랫폼 전역 설정이 `max_file_size: 100MB` +**When**: 특정 테넌트가 `max_file_size: 500MB`로 설정 +**Then**: 해당 테넌트는 500MB 제한을 사용하고, 다른 테넌트는 100MB 제한 유지 + +### 시나리오 3: 타입 기본값 적용 + +**Given**: 전역에 `support_level` 없음 +**And**: 타입(ENTERPRISE) 기본 `support_level=premium` +**When**: 테넌트가 미설정 +**Then**: `support_level=premium` 적용 + +### 시나리오 4: 타입 변경 반영 + +**Given**: 테넌트 타입 INDIVIDUAL → ENTERPRISE로 변경 +**When**: 타입 변경 이벤트 발생 +**Then**: 캐시 무효화 및 유효 설정에 ENTERPRISE 기본값 반영 + +## 캐시 관리 + +시스템은 자동으로 캐시를 관리합니다: + +- **설정 변경 시**: 해당 테넌트의 캐시 자동 무효화 +- **테넌트 타입 변경 시**: 해당 타입의 모든 테넌트 캐시 무효화 +- **플랫폼 설정 변경 시**: 모든 테넌트 캐시 무효화 + +수동 캐시 무효화: + +```http +POST /api/v1/tenants/{tenantKey}/configs/cache/evict +``` + +## 설정 타입 + +지원하는 설정 타입: + +- `STRING`: 문자열 +- `NUMBER`: 숫자 (정수/실수) +- `BOOLEAN`: 불린값 +- `JSON`: JSON 객체 +- `ENCRYPTED`: 암호화된 값 + +## 에러 처리 + +표준화된 에러 코드를 사용합니다: + +- `TENANT_CONFIG_NOT_FOUND`: 설정을 찾을 수 없음 +- `INVALID_CONFIG_VALUE`: 유효하지 않은 설정값 +- `CONFIG_KEY_ALREADY_EXISTS`: 중복된 설정 키 +- `CONFIG_INHERITANCE_FAILED`: 상속 처리 실패 + +## 성능 고려사항 + +- 설정 조회는 캐시를 통해 최적화됨 +- 설정 변경 시에만 캐시 무효화 +- 대량 설정 조회 시 배치 처리 권장 +- 설정 상속 계산은 비동기로 처리 가능 + +## 보안 고려사항 + +- 민감한 설정은 암호화 저장 +- 테넌트별 설정 접근 권한 검증 +- 감사 로깅으로 설정 변경 이력 추적 +- 설정값 유효성 검증 + +## 모니터링 + +- 설정 조회 성능 메트릭 +- 캐시 히트율 모니터링 +- 설정 변경 빈도 추적 +- 에러율 및 예외 상황 모니터링 + + + diff --git a/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java b/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java index 0b913d23..9b52f95a 100644 --- a/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java +++ b/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java @@ -1,116 +1,80 @@ package com.agenticcp.core.common.audit; -import com.agenticcp.core.common.context.AuditChangeContext; -import com.agenticcp.core.common.entity.AuditLog; -import com.agenticcp.core.common.util.ChangeTracker; -import jakarta.persistence.PreRemove; +import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import java.lang.reflect.Field; -import java.util.Map; +import java.time.LocalDateTime; /** - * JPA 엔티티 생명주기 리스너 + * 엔티티 감사 리스너 + * + *

엔티티의 생성/수정 시 자동으로 감사 정보를 설정합니다.

* - * 엔티티가 UPDATE/DELETE 되기 직전에 자동으로 변경 전 값을 캡처합니다. - * * @author AgenticCP Team * @version 1.0.0 + * @since 2024-01-01 */ -@Slf4j @Component public class AuditEntityListener { - private static ChangeTracker changeTracker; - - @Autowired - public void setChangeTracker(ChangeTracker tracker) { - AuditEntityListener.changeTracker = tracker; - } - - @PreUpdate - public void preUpdate(Object entity) { - if (entity instanceof AuditLog) { - return; // 감지된 엔티티가 AuditLog 자신이면, 아무것도 하지 않고 즉시 종료 - } - try { - if (changeTracker == null) { - log.warn("ChangeTracker가 주입되지 않았습니다. 변경 추적을 건너뜁니다."); - return; - } - - Map oldValue = changeTracker.extractOldValue(entity); - String entityId = extractEntityId(entity); + /** + * 엔티티 생성 전 감사 정보 설정 + */ + @PrePersist + public void prePersist(Object entity) { + if (entity instanceof com.agenticcp.core.common.entity.BaseEntity) { + com.agenticcp.core.common.entity.BaseEntity baseEntity = (com.agenticcp.core.common.entity.BaseEntity) entity; - if (oldValue != null && entityId != null) { - AuditChangeContext.setChangeData(oldValue, entityId); - log.debug("엔티티 변경 전 값 자동 캡처 [Entity: {}, ID: {}]", - entity.getClass().getSimpleName(), entityId); - } + LocalDateTime now = LocalDateTime.now(); + String currentUser = getCurrentUser(); - } catch (Exception e) { - log.warn("엔티티 변경 추적 중 오류 발생 [Entity: {}]: {}", - entity.getClass().getSimpleName(), e.getMessage()); + if (baseEntity.getCreatedAt() == null) { + baseEntity.setCreatedAt(now); + } + if (baseEntity.getUpdatedAt() == null) { + baseEntity.setUpdatedAt(now); + } + if (baseEntity.getCreatedBy() == null) { + baseEntity.setCreatedBy(currentUser); + } + if (baseEntity.getUpdatedBy() == null) { + baseEntity.setUpdatedBy(currentUser); + } + if (baseEntity.getIsDeleted() == null) { + baseEntity.setIsDeleted(false); + } } } - @PreRemove - public void preRemove(Object entity) { - try { - if (changeTracker == null) { - return; - } - - Map oldValue = changeTracker.extractOldValue(entity); - String entityId = extractEntityId(entity); - - if (oldValue != null && entityId != null) { - AuditChangeContext.setChangeData(oldValue, entityId); - log.debug("엔티티 삭제 전 값 자동 캡처 [Entity: {}, ID: {}]", - entity.getClass().getSimpleName(), entityId); - } + /** + * 엔티티 수정 전 감사 정보 설정 + */ + @PreUpdate + public void preUpdate(Object entity) { + if (entity instanceof com.agenticcp.core.common.entity.BaseEntity) { + com.agenticcp.core.common.entity.BaseEntity baseEntity = (com.agenticcp.core.common.entity.BaseEntity) entity; - } catch (Exception e) { - log.warn("엔티티 삭제 추적 중 오류 발생 [Entity: {}]: {}", - entity.getClass().getSimpleName(), e.getMessage()); + baseEntity.setUpdatedAt(LocalDateTime.now()); + baseEntity.setUpdatedBy(getCurrentUser()); } } - private String extractEntityId(Object entity) { + /** + * 현재 사용자 정보 조회 + */ + private String getCurrentUser() { try { - try { - Object id = entity.getClass().getMethod("getId").invoke(entity); - return id != null ? id.toString() : null; - } catch (NoSuchMethodException e) { - } - - for (Field field : entity.getClass().getDeclaredFields()) { - if (field.isAnnotationPresent(jakarta.persistence.Id.class)) { - field.setAccessible(true); - Object id = field.get(entity); - return id != null ? id.toString() : null; - } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && + !"anonymousUser".equals(authentication.getPrincipal())) { + return authentication.getName(); } - - Class superClass = entity.getClass().getSuperclass(); - if (superClass != null) { - for (Field field : superClass.getDeclaredFields()) { - if (field.isAnnotationPresent(jakarta.persistence.Id.class)) { - field.setAccessible(true); - Object id = field.get(entity); - return id != null ? id.toString() : null; - } - } - } - } catch (Exception e) { - log.debug("엔티티 ID 추출 실패: {}", e.getMessage()); + // SecurityContext가 없는 경우 무시 } - - return null; + return "system"; } -} - +} \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/common/config/RedisConfig.java b/src/main/java/com/agenticcp/core/common/config/RedisConfig.java index 3b0821a3..170ca6f3 100644 --- a/src/main/java/com/agenticcp/core/common/config/RedisConfig.java +++ b/src/main/java/com/agenticcp/core/common/config/RedisConfig.java @@ -18,6 +18,7 @@ */ @Configuration @ConditionalOnClass(RedisConnectionFactory.class) +@ConditionalOnProperty(name = "app.redis.enabled", havingValue = "true", matchIfMissing = false) public class RedisConfig { /** diff --git a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java index 1a2ea06c..5ca4b705 100644 --- a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java +++ b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java @@ -16,6 +16,8 @@ public enum AuditResourceType { USER("사용자"), TENANT("테넌트"), + TENANT_CONFIG("테넌트설정"), + TENANT_TYPE_CONFIG("테넌트타입설정"), POLICY("보안정책"), ROLE("역할"), PERMISSION("권한"), @@ -33,4 +35,4 @@ public enum AuditResourceType { this.description = description; } -} +} \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java b/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java index a10ff1a8..2930c222 100644 --- a/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java @@ -72,10 +72,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse * 요청에서 JWT 토큰 추출 */ private String extractTokenFromRequest(HttpServletRequest request) { - String authHeader = request.getHeader(AUTHORIZATION_HEADER); + String authHeader = request.getHeader(JwtConstants.AUTHORIZATION_HEADER); - if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) { - return authHeader.substring(BEARER_PREFIX.length()); + if (StringUtils.hasText(authHeader) && authHeader.startsWith(JwtConstants.BEARER_PREFIX)) { + return authHeader.substring(JwtConstants.BEARER_PREFIX.length()); } return null; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java new file mode 100644 index 00000000..06a2ee52 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java @@ -0,0 +1,271 @@ +package com.agenticcp.core.domain.tenant.controller; + +import com.agenticcp.core.common.audit.AuditController; +import com.agenticcp.core.common.audit.AuditRequired; +import com.agenticcp.core.common.dto.exception.ApiResponse; +import com.agenticcp.core.common.enums.AuditResourceType; +import com.agenticcp.core.common.enums.AuditSeverity; +import com.agenticcp.core.common.util.LogMaskingUtils; +import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse; +import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService; +import com.agenticcp.core.domain.tenant.service.TenantConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +/** + * 테넌트 설정 관리 컨트롤러 + * + * 테넌트별 설정 상속 시스템의 API를 제공합니다. + * 개별 테넌트 설정(TenantConfig)의 생성, 조회, 수정, 삭제 및 + * 계층적 상속을 통한 유효 설정 조회 기능을 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/tenants/{tenantKey}/configs") +@RequiredArgsConstructor +@Tag(name = "Tenant Configuration", description = "테넌트 설정 관리 API") +@AuditController( + resourceType = AuditResourceType.TENANT_CONFIG, + defaultSeverity = AuditSeverity.MEDIUM, + defaultIncludeRequestData = true, + targetHttpMethods = {"POST", "PUT", "DELETE"} +) +public class TenantConfigController { + + private final TenantConfigService tenantConfigService; + private final TenantConfigCacheService tenantConfigCacheService; + + /** + * 테넌트의 유효 설정 전체 조회 (상속 포함) + * + * 플랫폼, 테넌트 타입, 개별 테넌트 설정을 계층적으로 상속하여 + * 최종 유효 설정을 모두 조회합니다. 캐시를 활용하여 성능을 최적화합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @return 유효 설정 응답 (설정값과 출처 정보 포함) + */ + @GetMapping("/effective") + @Operation(summary = "테넌트 유효 설정 조회", + description = "플랫폼 전역 설정, 테넌트 타입 기본 설정, 개별 테넌트 설정을 상속하여 최종 유효 설정을 조회합니다.") + @AuditRequired( + action = "getEffectiveConfigurations", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity> getEffectiveConfigurations( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey) { + + log.info("[TenantConfigController] getEffectiveConfigurations - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + EffectiveConfigResponse response = tenantConfigCacheService.getCachedConfigurations(tenantKey); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 테넌트의 특정 유효 설정 단건 조회 + * + * 특정 설정 키에 대한 테넌트의 유효 설정값을 조회합니다. + * 상속 계층(개별 → 타입 → 플랫폼)을 따라 우선순위가 높은 값을 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @param configKey 조회할 설정 키 + * @return 유효 설정값 (파싱된 객체), 없으면 null + */ + @GetMapping("/effective/{configKey}") + @Operation(summary = "테넌트 특정 유효 설정 조회", + description = "특정 설정 키에 대한 테넌트의 유효 설정값을 조회합니다.") + @AuditRequired( + action = "getEffectiveConfiguration", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity> getEffectiveConfiguration( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey, + @Parameter(description = "설정 키") @PathVariable String configKey) { + + log.info("[TenantConfigController] getEffectiveConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + Object value = tenantConfigCacheService.getCachedConfiguration(tenantKey, configKey); + + return ResponseEntity.ok(ApiResponse.success(value)); + } + + /** + * 테넌트의 개별 설정 목록 조회 + * + * 테넌트가 직접 설정한 모든 개별 설정을 조회합니다. + * 상속된 설정이 아닌 TenantConfig 레벨의 설정만 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @return 테넌트의 개별 설정 목록 + */ + @GetMapping + @Operation(summary = "테넌트 개별 설정 목록 조회", + description = "테넌트가 직접 설정한 개별 설정 목록을 조회합니다.") + @AuditRequired( + action = "getTenantConfigurations", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity>> getTenantConfigurations( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey) { + + log.info("[TenantConfigController] getTenantConfigurations - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + List configs = tenantConfigService.getTenantConfigurations(tenantKey); + + return ResponseEntity.ok(ApiResponse.success(configs)); + } + + /** + * 테넌트의 특정 개별 설정 단건 조회 + * + * 테넌트가 직접 설정한 특정 개별 설정을 조회합니다. + * 상속된 값이 아닌 TenantConfig 레벨의 설정만 조회합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @param configKey 조회할 설정 키 + * @return 테넌트의 개별 설정 (없으면 null) + */ + @GetMapping("/{configKey}") + @Operation(summary = "테넌트 특정 개별 설정 조회", + description = "테넌트가 직접 설정한 특정 설정을 조회합니다.") + @AuditRequired( + action = "getTenantConfiguration", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity> getTenantConfiguration( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey, + @Parameter(description = "설정 키") @PathVariable String configKey) { + + log.info("[TenantConfigController] getTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + return tenantConfigService.getTenantConfiguration(tenantKey, configKey) + .map(config -> ResponseEntity.ok(ApiResponse.success(config))) + .orElse(ResponseEntity.ok(ApiResponse.success(null))); + } + + /** + * 테넌트 설정 생성/수정 (Upsert) + * + * 테넌트의 개별 설정을 생성하거나 수정합니다. + * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다. + * 저장 후 자동으로 캐시를 무효화합니다. + * + * @param tenantKey 설정을 저장할 테넌트 키 + * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부) + * @return 저장된 테넌트 설정 + */ + @PostMapping + @Operation(summary = "테넌트 설정 생성/수정", + description = "테넌트의 개별 설정을 생성하거나 수정합니다.") + @AuditRequired( + action = "saveTenantConfiguration", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.MEDIUM, + includeRequestData = true + ) + public ResponseEntity> saveTenantConfiguration( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey, + @Valid @RequestBody TenantConfigRequest request) { + + log.info("[TenantConfigController] saveTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey()); + + TenantConfig config = tenantConfigService.saveTenantConfiguration(tenantKey, request); + + // 캐시 무효화: 해당 테넌트의 설정 캐시를 갱신 + tenantConfigCacheService.evictTenantConfigCache(tenantKey); + + return ResponseEntity.ok(ApiResponse.success(config)); + } + + /** + * 테넌트 설정 삭제 (소프트 삭제) + * + * 테넌트의 특정 개별 설정을 삭제합니다. + * 물리적 삭제가 아닌 isDeleted 플래그를 설정하는 소프트 삭제입니다. + * 삭제 후 자동으로 캐시를 무효화합니다. + * + * @param tenantKey 삭제할 설정이 속한 테넌트 키 + * @param configKey 삭제할 설정 키 + * @return 성공 응답 + */ + @DeleteMapping("/{configKey}") + @Operation(summary = "테넌트 설정 삭제", + description = "테넌트의 개별 설정을 삭제합니다.") + @AuditRequired( + action = "deleteTenantConfiguration", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.MEDIUM, + includeRequestData = true + ) + public ResponseEntity> deleteTenantConfiguration( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey, + @Parameter(description = "설정 키") @PathVariable String configKey) { + + log.info("[TenantConfigController] deleteTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + tenantConfigService.deleteTenantConfiguration(tenantKey, configKey); + + // 캐시 무효화: 해당 테넌트의 특정 설정 캐시를 제거 + tenantConfigCacheService.evictTenantConfigCache(tenantKey, configKey); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 테넌트 설정 캐시 강제 무효화 + * + * 테넌트의 설정 캐시를 수동으로 무효화합니다. + * 캐시 동기화 문제가 발생하거나 강제로 최신 상태로 갱신해야 할 때 사용합니다. + * 주의: 이 API는 관리자 또는 디버깅 용도로만 사용해야 합니다. + * + * @param tenantKey 캐시를 무효화할 테넌트 키 + * @return 성공 응답 + */ + @PostMapping("/cache/evict") + @Operation(summary = "테넌트 설정 캐시 무효화", + description = "테넌트의 설정 캐시를 강제로 무효화합니다.") + @AuditRequired( + action = "evictTenantConfigCache", + resourceType = AuditResourceType.TENANT_CONFIG, + severity = AuditSeverity.HIGH, + includeRequestData = false + ) + public ResponseEntity> evictTenantConfigCache( + @Parameter(description = "테넌트 키") @PathVariable String tenantKey) { + + log.info("[TenantConfigController] evictTenantConfigCache - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + tenantConfigCacheService.evictTenantConfigCache(tenantKey); + + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java new file mode 100644 index 00000000..3a280a30 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java @@ -0,0 +1,256 @@ +package com.agenticcp.core.domain.tenant.controller; + +import com.agenticcp.core.common.audit.AuditController; +import com.agenticcp.core.common.audit.AuditRequired; +import com.agenticcp.core.common.dto.exception.ApiResponse; +import com.agenticcp.core.common.enums.AuditResourceType; +import com.agenticcp.core.common.enums.AuditSeverity; +import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig; +import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository; +import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Optional; + +/** + * 테넌트 타입별 기본 설정 관리 컨트롤러 + * + * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)별 기본 설정을 관리하는 API를 제공합니다. + * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값으로 사용되며, + * 개별 테넌트는 이 값을 오버라이드할 수 있습니다. + * + * 설정 우선순위: TenantConfig > TenantTypeConfig > PlatformConfig + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/tenant-types/{tenantType}/configs") +@RequiredArgsConstructor +@Tag(name = "Tenant Type Configuration", description = "테넌트 타입별 설정 관리 API") +@AuditController( + resourceType = AuditResourceType.TENANT_TYPE_CONFIG, + defaultSeverity = AuditSeverity.HIGH, + defaultIncludeRequestData = true, + targetHttpMethods = {"POST", "PUT", "DELETE"} +) +public class TenantTypeConfigController { + + private final TenantTypeConfigRepository tenantTypeConfigRepository; + private final TenantConfigCacheService tenantConfigCacheService; + + /** + * 테넌트 타입의 모든 기본 설정 목록 조회 + * + * 특정 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 모든 기본 설정을 조회합니다. + * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값입니다. + * + * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL) + * @return 테넌트 타입의 기본 설정 목록 + */ + @GetMapping + @Operation(summary = "테넌트 타입 설정 목록 조회", + description = "특정 테넌트 타입의 모든 기본 설정을 조회합니다.") + @AuditRequired( + action = "getTenantTypeConfigurations", + resourceType = AuditResourceType.TENANT_TYPE_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity>> getTenantTypeConfigurations( + @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType) { + + log.info("[TenantTypeConfigController] getTenantTypeConfigurations - tenantType={}", tenantType); + + List configs = tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(tenantType); + + return ResponseEntity.ok(ApiResponse.success(configs)); + } + + /** + * 테넌트 타입의 특정 기본 설정 단건 조회 + * + * 테넌트 타입의 특정 기본 설정을 조회합니다. + * 해당 타입의 모든 테넌트에 적용되는 기본값을 확인할 때 사용합니다. + * + * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL) + * @param configKey 조회할 설정 키 + * @return 테넌트 타입의 기본 설정 (없으면 null) + */ + @GetMapping("/{configKey}") + @Operation(summary = "테넌트 타입 특정 설정 조회", + description = "테넌트 타입의 특정 기본 설정을 조회합니다.") + @AuditRequired( + action = "getTenantTypeConfiguration", + resourceType = AuditResourceType.TENANT_TYPE_CONFIG, + severity = AuditSeverity.LOW, + includeRequestData = false + ) + public ResponseEntity> getTenantTypeConfiguration( + @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType, + @Parameter(description = "설정 키") @PathVariable String configKey) { + + log.info("[TenantTypeConfigController] getTenantTypeConfiguration - tenantType={}, configKey={}", + tenantType, configKey); + + Optional config = tenantTypeConfigRepository + .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, configKey); + + return config.map(c -> ResponseEntity.ok(ApiResponse.success(c))) + .orElse(ResponseEntity.ok(ApiResponse.success(null))); + } + + /** + * 테넌트 타입 기본 설정 생성/수정 (Upsert) + * + * 테넌트 타입의 기본 설정을 생성하거나 수정합니다. + * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다. + * + * 주의: 이 설정은 해당 타입의 모든 테넌트에 영향을 줍니다. + * 저장 후 해당 타입의 모든 테넌트 캐시를 자동으로 무효화합니다. + * + * @param tenantType 설정을 저장할 테넌트 타입 + * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부) + * @return 저장된 테넌트 타입 기본 설정 + */ + @PostMapping + @Operation(summary = "테넌트 타입 설정 생성/수정", + description = "테넌트 타입의 기본 설정을 생성하거나 수정합니다.") + @AuditRequired( + action = "saveTenantTypeConfiguration", + resourceType = AuditResourceType.TENANT_TYPE_CONFIG, + severity = AuditSeverity.HIGH, + includeRequestData = true + ) + public ResponseEntity> saveTenantTypeConfiguration( + @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType, + @Valid @RequestBody TenantConfigRequest request) { + + log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - tenantType={}, configKey={}", + tenantType, request.getConfigKey()); + + // Upsert 패턴: 기존 설정이 있는지 확인 + Optional existingConfig = tenantTypeConfigRepository + .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, request.getConfigKey()); + + TenantTypeConfig config; + if (existingConfig.isPresent()) { + // 기존 설정 수정 (UPDATE) + config = existingConfig.get(); + config.setConfigValue(request.getConfigValue()); + config.setConfigType(mapConfigType(request.getConfigType())); + config.setDescription(request.getDescription()); + config.setIsEncrypted(request.getIsEncrypted()); + log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - updated existing config"); + } else { + // 신규 설정 생성 (INSERT) + config = TenantTypeConfig.builder() + .tenantType(tenantType) + .configKey(request.getConfigKey()) + .configValue(request.getConfigValue()) + .configType(mapConfigType(request.getConfigType())) + .description(request.getDescription()) + .isEncrypted(request.getIsEncrypted()) + .build(); + log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - created new config"); + } + + TenantTypeConfig savedConfig = tenantTypeConfigRepository.save(config); + + // 캐시 무효화: 해당 타입을 사용하는 모든 테넌트의 설정 캐시 갱신 + tenantConfigCacheService.evictCacheByTenantType(tenantType.name()); + + return ResponseEntity.ok(ApiResponse.success(savedConfig)); + } + + /** + * 테넌트 타입 기본 설정 삭제 (소프트 삭제) + * + * 테넌트 타입의 특정 기본 설정을 삭제합니다. + * 물리적 삭제가 아닌 isDeleted 플래그를 설정하는 소프트 삭제입니다. + * + * 주의: 이 설정은 해당 타입의 모든 테넌트에 영향을 줍니다. + * 삭제 후 해당 타입의 모든 테넌트 캐시를 자동으로 무효화합니다. + * + * @param tenantType 삭제할 설정이 속한 테넌트 타입 + * @param configKey 삭제할 설정 키 + * @return 성공 응답 + */ + @DeleteMapping("/{configKey}") + @Operation(summary = "테넌트 타입 설정 삭제", + description = "테넌트 타입의 기본 설정을 삭제합니다.") + @AuditRequired( + action = "deleteTenantTypeConfiguration", + resourceType = AuditResourceType.TENANT_TYPE_CONFIG, + severity = AuditSeverity.HIGH, + includeRequestData = true + ) + public ResponseEntity> deleteTenantTypeConfiguration( + @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType, + @Parameter(description = "설정 키") @PathVariable String configKey) { + + log.info("[TenantTypeConfigController] deleteTenantTypeConfiguration - tenantType={}, configKey={}", + tenantType, configKey); + + Optional configOpt = tenantTypeConfigRepository + .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, configKey); + + if (configOpt.isPresent()) { + // 소프트 삭제: isDeleted 플래그만 설정 + TenantTypeConfig config = configOpt.get(); + config.setIsDeleted(true); + tenantTypeConfigRepository.save(config); + + log.info("[TenantTypeConfigController] deleteTenantTypeConfiguration - soft deleted successfully"); + + // 캐시 무효화: 해당 타입을 사용하는 모든 테넌트의 설정 캐시 갱신 + tenantConfigCacheService.evictCacheByTenantType(tenantType.name()); + } else { + log.warn("[TenantTypeConfigController] deleteTenantTypeConfiguration - config not found"); + } + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * ConfigType 매핑 헬퍼 메서드 + * + * TenantConfig.ConfigType을 TenantTypeConfig.ConfigType으로 변환합니다. + * TenantConfig와 TenantTypeConfig는 동일한 ConfigType enum을 가지지만 + * 패키지가 다르기 때문에 매핑이 필요합니다. + * + * @param tenantConfigType 변환할 TenantConfig의 ConfigType + * @return 변환된 TenantTypeConfig의 ConfigType (기본값: STRING) + */ + private TenantTypeConfig.ConfigType mapConfigType(com.agenticcp.core.domain.tenant.entity.TenantConfig.ConfigType tenantConfigType) { + // ConfigType 1:1 매핑 + switch (tenantConfigType) { + case STRING: + return TenantTypeConfig.ConfigType.STRING; + case NUMBER: + return TenantTypeConfig.ConfigType.NUMBER; + case BOOLEAN: + return TenantTypeConfig.ConfigType.BOOLEAN; + case JSON: + return TenantTypeConfig.ConfigType.JSON; + case ENCRYPTED: + return TenantTypeConfig.ConfigType.ENCRYPTED; + default: + // 알 수 없는 타입의 경우 STRING으로 폴백 + log.warn("[TenantTypeConfigController] mapConfigType - unknown type: {}, fallback to STRING", tenantConfigType); + return TenantTypeConfig.ConfigType.STRING; + } + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java b/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java new file mode 100644 index 00000000..ed8554c8 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java @@ -0,0 +1,41 @@ +package com.agenticcp.core.domain.tenant.dto; + +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 테넌트의 유효 설정 응답 DTO + * 설정값과 그 출처 정보를 포함합니다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EffectiveConfigResponse { + + private String tenantKey; + private Map configurations; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ConfigValueWithSource { + private Object value; + private ConfigSource source; + private String description; + private TenantConfig.ConfigType configType; + } + + public enum ConfigSource { + PLATFORM, // 플랫폼 전역 설정 + TENANT_TYPE, // 테넌트 타입 기본 설정 + TENANT // 개별 테넌트 설정 + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java new file mode 100644 index 00000000..a7105d00 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java @@ -0,0 +1,34 @@ +package com.agenticcp.core.domain.tenant.dto; + +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 테넌트 설정 생성/수정 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TenantConfigRequest { + + @NotBlank(message = "설정 키는 필수입니다") + private String configKey; + + @NotBlank(message = "설정 값은 필수입니다") + private String configValue; + + @NotNull(message = "설정 타입은 필수입니다") + private TenantConfig.ConfigType configType; + + private String description; + + @Builder.Default + private Boolean isEncrypted = false; +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java index c0c664a0..0eb91bc6 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java @@ -2,52 +2,154 @@ import com.agenticcp.core.common.entity.BaseEntity; import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +/** + * 테넌트별 설정 엔티티 + * + * 개별 테넌트의 설정값을 저장하고 관리합니다. + * 플랫폼 설정과 테넌트 타입 설정을 상속받아 오버라이드할 수 있습니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ @Entity -@Table(name = "tenant_configs") +@Table(name = "tenant_configs", + uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_id", "config_key"})) @Data +@EqualsAndHashCode(callSuper = false) @Builder @NoArgsConstructor @AllArgsConstructor public class TenantConfig extends BaseEntity { + /** + * 설정이 속한 테넌트 + */ + @NotNull(message = "테넌트는 필수입니다") @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "tenant_id", nullable = false) private Tenant tenant; - @Column(name = "config_key", nullable = false) + /** + * 설정 키 (예: max_users, storage_limit) + */ + @NotBlank(message = "설정 키는 필수입니다") + @Size(min = 1, max = 100, message = "설정 키는 1-100자 사이여야 합니다") + @Column(name = "config_key", nullable = false, length = 100) private String configKey; + /** + * 설정 값 (JSON, 문자열, 숫자 등 다양한 형식 지원) + */ @Column(name = "config_value", columnDefinition = "TEXT") private String configValue; - @Column(name = "config_type") + /** + * 설정 값의 타입 + */ + @NotNull(message = "설정 타입은 필수입니다") + @Column(name = "config_type", nullable = false, length = 20) @Enumerated(EnumType.STRING) private ConfigType configType; - @Column(name = "description") + /** + * 설정에 대한 설명 + */ + @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다") + @Column(name = "description", length = 500) private String description; - @Column(name = "is_encrypted") + /** + * 값이 암호화되어 저장되었는지 여부 + */ + @Builder.Default + @Column(name = "is_encrypted", nullable = false) private Boolean isEncrypted = false; - @Column(name = "is_required") - private Boolean isRequired = false; - - @Column(name = "default_value", columnDefinition = "TEXT") - private String defaultValue; - + /** + * 설정 값의 데이터 타입 + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ public enum ConfigType { + /** + * 문자열 타입 + */ STRING, + + /** + * 숫자 타입 (정수, 실수) + */ NUMBER, + + /** + * 불리언 타입 (true/false) + */ BOOLEAN, + + /** + * JSON 객체 또는 배열 + */ JSON, - ENCRYPTED, - URL, - EMAIL + + /** + * 암호화된 값 + */ + ENCRYPTED + } + + // === 비즈니스 메서드 === + + /** + * 설정 값이 암호화되어 있는지 확인 + * + * @return 암호화 여부 + */ + public boolean isEncrypted() { + return Boolean.TRUE.equals(this.isEncrypted); + } + + /** + * 설정을 암호화 상태로 변경 + */ + public void markAsEncrypted() { + this.isEncrypted = true; + } + + /** + * 설정을 복호화 상태로 변경 + */ + public void markAsDecrypted() { + this.isEncrypted = false; + } + + /** + * 설정 값이 비어있는지 확인 + * + * @return 값이 null이거나 빈 문자열인 경우 true + */ + public boolean hasValue() { + return configValue != null && !configValue.trim().isEmpty(); + } + + /** + * 특정 타입인지 확인 + * + * @param type 확인할 타입 + * @return 동일한 타입이면 true + */ + public boolean isType(ConfigType type) { + return this.configType == type; } -} +} \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java new file mode 100644 index 00000000..7a81d7fd --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java @@ -0,0 +1,153 @@ +package com.agenticcp.core.domain.tenant.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * 테넌트 타입별 기본 설정 엔티티 + * + * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)에 따른 기본 설정값을 저장합니다. + * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값으로 사용됩니다. + * 개별 테넌트는 이 값을 오버라이드할 수 있습니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Entity +@Table(name = "tenant_type_configs", + uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_type", "config_key"}), + indexes = { + @Index(name = "idx_tenant_type_configs_type", columnList = "tenant_type"), + @Index(name = "idx_tenant_type_configs_key", columnList = "config_key"), + @Index(name = "idx_tenant_type_configs_config_type", columnList = "config_type") + }) +@Data +@EqualsAndHashCode(callSuper = false) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TenantTypeConfig extends BaseEntity { + + @NotNull(message = "테넌트 타입은 필수입니다") + @Column(name = "tenant_type", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private Tenant.TenantType tenantType; + + @NotBlank(message = "설정 키는 필수입니다") + @Size(min = 1, max = 100, message = "설정 키는 1-100자 사이여야 합니다") + @Column(name = "config_key", nullable = false, length = 100) + private String configKey; + + @Column(name = "config_value", columnDefinition = "TEXT") + private String configValue; + + @NotNull(message = "설정 타입은 필수입니다") + @Column(name = "config_type", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private ConfigType configType; + + @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다") + @Column(name = "description", length = 500) + private String description; + + @Builder.Default + @Column(name = "is_encrypted", nullable = false) + private Boolean isEncrypted = false; + + /** + * 설정 값의 데이터 타입 + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ + public enum ConfigType { + /** + * 문자열 타입 + */ + STRING, + + /** + * 숫자 타입 (정수, 실수) + */ + NUMBER, + + /** + * 불리언 타입 (true/false) + */ + BOOLEAN, + + /** + * JSON 객체 또는 배열 + */ + JSON, + + /** + * 암호화된 값 + */ + ENCRYPTED + } + + // === 비즈니스 메서드 === + + /** + * 설정 값이 암호화되어 있는지 확인 + * + * @return 암호화 여부 + */ + public boolean isEncrypted() { + return Boolean.TRUE.equals(this.isEncrypted); + } + + /** + * 설정을 암호화 상태로 변경 + */ + public void markAsEncrypted() { + this.isEncrypted = true; + } + + /** + * 설정을 복호화 상태로 변경 + */ + public void markAsDecrypted() { + this.isEncrypted = false; + } + + /** + * 설정 값이 비어있는지 확인 + * + * @return 값이 null이거나 빈 문자열인 경우 true + */ + public boolean hasValue() { + return configValue != null && !configValue.trim().isEmpty(); + } + + /** + * 특정 타입인지 확인 + * + * @param type 확인할 타입 + * @return 동일한 타입이면 true + */ + public boolean isType(ConfigType type) { + return this.configType == type; + } + + /** + * 특정 테넌트 타입에 대한 설정인지 확인 + * + * @param type 확인할 테넌트 타입 + * @return 동일한 테넌트 타입이면 true + */ + public boolean isForTenantType(Tenant.TenantType type) { + return this.tenantType == type; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java b/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java new file mode 100644 index 00000000..537f9df5 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java @@ -0,0 +1,36 @@ +package com.agenticcp.core.domain.tenant.enums; + +import com.agenticcp.core.common.dto.exception.BaseErrorCode; +import com.agenticcp.core.common.enums.ErrorCategory; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 테넌트 설정 관련 에러 코드 + */ +@Getter +@RequiredArgsConstructor +public enum TenantConfigErrorCode implements BaseErrorCode { + + // 3000-3999: 테넌트 도메인 + TENANT_CONFIG_NOT_FOUND(HttpStatus.NOT_FOUND, 3001, "테넌트 설정을 찾을 수 없습니다."), + TENANT_TYPE_CONFIG_NOT_FOUND(HttpStatus.NOT_FOUND, 3002, "테넌트 타입별 설정을 찾을 수 없습니다."), + INVALID_CONFIG_VALUE(HttpStatus.BAD_REQUEST, 3003, "유효하지 않은 설정값입니다."), + CONFIG_KEY_ALREADY_EXISTS(HttpStatus.CONFLICT, 3004, "이미 존재하는 설정 키입니다."), + INVALID_CONFIG_TYPE(HttpStatus.BAD_REQUEST, 3005, "유효하지 않은 설정 타입입니다."), + CONFIG_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3006, "설정 암호화에 실패했습니다."), + CONFIG_DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3007, "설정 복호화에 실패했습니다."), + TENANT_CONFIG_QUOTA_EXCEEDED(HttpStatus.BAD_REQUEST, 3008, "테넌트 설정 할당량을 초과했습니다."), + CONFIG_INHERITANCE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3009, "설정 상속 처리에 실패했습니다."), + CACHE_EVICTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3010, "캐시 무효화에 실패했습니다."); + + private final HttpStatus httpStatus; + private final int codeNumber; + private final String message; + + @Override + public String getCode() { + return ErrorCategory.TENANT.generate(codeNumber); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java new file mode 100644 index 00000000..5ed8ea53 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.tenant.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * 플랫폼 설정 변경 이벤트 + */ +@Getter +public class PlatformConfigChangeEvent extends ApplicationEvent { + + private final String configKey; + private final String action; // CREATE, UPDATE, DELETE + + public PlatformConfigChangeEvent(Object source, String configKey, String action) { + super(source); + this.configKey = configKey; + this.action = action; + } +} + + + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java new file mode 100644 index 00000000..729007fd --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java @@ -0,0 +1,25 @@ +package com.agenticcp.core.domain.tenant.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * 테넌트 설정 변경 이벤트 + */ +@Getter +public class TenantConfigChangeEvent extends ApplicationEvent { + + private final String tenantKey; + private final String configKey; + private final String action; // CREATE, UPDATE, DELETE + + public TenantConfigChangeEvent(Object source, String tenantKey, String configKey, String action) { + super(source); + this.tenantKey = tenantKey; + this.configKey = configKey; + this.action = action; + } +} + + + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java new file mode 100644 index 00000000..f341da8d --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java @@ -0,0 +1,80 @@ +package com.agenticcp.core.domain.tenant.event; + +import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService; +import com.agenticcp.core.common.util.LogMaskingUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * 테넌트 설정 관련 이벤트 리스너 + * + * 테넌트 설정 변경, 테넌트 타입 변경 등의 이벤트를 감지하여 + * 관련 캐시를 자동으로 무효화합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TenantConfigEventListener { + + private final TenantConfigCacheService tenantConfigCacheService; + + /** + * 테넌트 타입 변경 이벤트 처리 + */ + @EventListener + public void handleTenantTypeChangeEvent(TenantTypeChangeEvent event) { + log.info("[TenantConfigEventListener] handleTenantTypeChangeEvent - tenantKey={}, oldType={}, newType={}", + LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getOldType(), event.getNewType()); + + // 해당 테넌트의 캐시 무효화 + tenantConfigCacheService.evictTenantConfigCache(event.getTenantKey()); + + log.info("[TenantConfigEventListener] handleTenantTypeChangeEvent - cache evicted for tenantKey={}", + LogMaskingUtils.maskTenantKey(event.getTenantKey())); + } + + /** + * 테넌트 설정 변경 이벤트 처리 + */ + @EventListener + public void handleTenantConfigChangeEvent(TenantConfigChangeEvent event) { + log.info("[TenantConfigEventListener] handleTenantConfigChangeEvent - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getConfigKey()); + + // 해당 테넌트의 캐시 무효화 + tenantConfigCacheService.evictTenantConfigCache(event.getTenantKey(), event.getConfigKey()); + + log.info("[TenantConfigEventListener] handleTenantConfigChangeEvent - cache evicted for tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getConfigKey()); + } + + /** + * 플랫폼 설정 변경 이벤트 처리 + */ + @EventListener + public void handlePlatformConfigChangeEvent(PlatformConfigChangeEvent event) { + log.info("[TenantConfigEventListener] handlePlatformConfigChangeEvent - configKey={}", event.getConfigKey()); + + // 플랫폼 설정 변경으로 인한 모든 테넌트 캐시 무효화 + tenantConfigCacheService.evictCacheByPlatformConfigChange(); + + log.info("[TenantConfigEventListener] handlePlatformConfigChangeEvent - all tenant caches evicted"); + } + + /** + * 테넌트 타입별 설정 변경 이벤트 처리 + */ + @EventListener + public void handleTenantTypeConfigChangeEvent(TenantTypeConfigChangeEvent event) { + log.info("[TenantConfigEventListener] handleTenantTypeConfigChangeEvent - tenantType={}, configKey={}", + event.getTenantType(), event.getConfigKey()); + + // 해당 타입을 사용하는 모든 테넌트의 캐시 무효화 + tenantConfigCacheService.evictCacheByTenantType(event.getTenantType()); + + log.info("[TenantConfigEventListener] handleTenantTypeConfigChangeEvent - cache evicted for tenantType={}", + event.getTenantType()); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java new file mode 100644 index 00000000..3a2fb439 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java @@ -0,0 +1,26 @@ +package com.agenticcp.core.domain.tenant.event; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * 테넌트 타입 변경 이벤트 + */ +@Getter +public class TenantTypeChangeEvent extends ApplicationEvent { + + private final String tenantKey; + private final Tenant.TenantType oldType; + private final Tenant.TenantType newType; + + public TenantTypeChangeEvent(Object source, String tenantKey, Tenant.TenantType oldType, Tenant.TenantType newType) { + super(source); + this.tenantKey = tenantKey; + this.oldType = oldType; + this.newType = newType; + } +} + + + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java new file mode 100644 index 00000000..2fef00bb --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java @@ -0,0 +1,33 @@ +package com.agenticcp.core.domain.tenant.event; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * 테넌트 타입별 설정 변경 이벤트 + */ +@Getter +public class TenantTypeConfigChangeEvent extends ApplicationEvent { + + private final String tenantType; + private final String configKey; + private final String action; // CREATE, UPDATE, DELETE + + public TenantTypeConfigChangeEvent(Object source, String tenantType, String configKey, String action) { + super(source); + this.tenantType = tenantType; + this.configKey = configKey; + this.action = action; + } + + public TenantTypeConfigChangeEvent(Object source, Tenant.TenantType tenantType, String configKey, String action) { + super(source); + this.tenantType = tenantType.name(); + this.configKey = configKey; + this.action = action; + } +} + + + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java new file mode 100644 index 00000000..305dd407 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java @@ -0,0 +1,90 @@ +package com.agenticcp.core.domain.tenant.repository; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 테넌트 개별 설정 Repository + * + * 개별 테넌트의 설정값을 조회하고 관리하는 데이터 액세스 레이어입니다. + * TenantConfig 엔티티에 대한 CRUD 및 커스텀 쿼리를 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface TenantConfigRepository extends JpaRepository { + + /** + * 테넌트 엔티티로 모든 개별 설정 조회 + * + * 특정 테넌트의 모든 설정을 조회합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenant 조회할 테넌트 엔티티 + * @return 테넌트의 개별 설정 목록 (삭제되지 않은 것만) + */ + List findByTenantAndIsDeletedFalse(Tenant tenant); + + /** + * 테넌트 엔티티와 설정 키로 특정 개별 설정 조회 + * + * 특정 테넌트의 특정 설정 키에 해당하는 설정을 조회합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenant 조회할 테넌트 엔티티 + * @param configKey 조회할 설정 키 + * @return 테넌트의 특정 설정 (없으면 empty) + */ + Optional findByTenantAndConfigKeyAndIsDeletedFalse(Tenant tenant, String configKey); + + /** + * 테넌트 키로 모든 개별 설정 조회 + * + * 테넌트 엔티티 없이 tenantKey 문자열만으로 설정을 조회합니다. + * Tenant 엔티티와 조인하여 tenantKey 기반으로 조회합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 (예: "tenant-001") + * @return 테넌트의 개별 설정 목록 (삭제되지 않은 것만) + */ + @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE t.tenantKey = :tenantKey AND tc.isDeleted = false") + List findByTenantKey(@Param("tenantKey") String tenantKey); + + /** + * 테넌트 키와 설정 키로 특정 개별 설정 조회 + * + * 테넌트 엔티티 없이 tenantKey와 configKey 문자열만으로 설정을 조회합니다. + * Tenant 엔티티와 조인하여 tenantKey 기반으로 조회합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 (예: "tenant-001") + * @param configKey 조회할 설정 키 (예: "max_users") + * @return 테넌트의 특정 설정 (없으면 empty) + */ + @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE t.tenantKey = :tenantKey AND tc.configKey = :configKey AND tc.isDeleted = false") + Optional findByTenantKeyAndConfigKey(@Param("tenantKey") String tenantKey, @Param("configKey") String configKey); + + /** + * 설정 키로 모든 테넌트의 설정 조회 + * + * 특정 설정 키를 가진 모든 테넌트의 설정을 조회합니다. + * 동일한 설정 키를 사용하는 여러 테넌트의 설정을 비교하거나 + * 일괄 조회할 때 사용합니다. (예: 모든 테넌트의 "max_users" 설정 조회) + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param configKey 조회할 설정 키 (예: "max_users") + * @return 해당 설정 키를 가진 모든 테넌트의 설정 목록 + */ + @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE tc.configKey = :configKey AND tc.isDeleted = false") + List findByConfigKey(@Param("configKey") String configKey); +} + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java new file mode 100644 index 00000000..4f03e0d5 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java @@ -0,0 +1,63 @@ +package com.agenticcp.core.domain.tenant.repository; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 테넌트 타입별 기본 설정 Repository + * + * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)별 기본 설정값을 조회하고 관리하는 + * 데이터 액세스 레이어입니다. TenantTypeConfig 엔티티에 대한 CRUD 및 + * 커스텀 쿼리를 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface TenantTypeConfigRepository extends JpaRepository { + + /** + * 테넌트 타입으로 모든 기본 설정 조회 + * + * 특정 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 모든 기본 설정을 조회합니다. + * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값입니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL) + * @return 테넌트 타입의 기본 설정 목록 (삭제되지 않은 것만) + */ + List findByTenantTypeAndIsDeletedFalse(Tenant.TenantType tenantType); + + /** + * 테넌트 타입과 설정 키로 특정 기본 설정 조회 + * + * 특정 테넌트 타입의 특정 설정 키에 해당하는 기본 설정을 조회합니다. + * 해당 타입의 모든 테넌트에 적용되는 기본값을 확인할 때 사용합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL) + * @param configKey 조회할 설정 키 (예: "max_users") + * @return 테넌트 타입의 특정 기본 설정 (없으면 empty) + */ + Optional findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType tenantType, String configKey); + + /** + * 모든 테넌트 타입의 기본 설정 일괄 조회 + * + * 모든 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 기본 설정을 + * 한 번에 조회합니다. 전체 설정 비교, 초기 설정 로드, 관리자 페이지 등에서 + * 사용합니다. 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @return 모든 테넌트 타입의 기본 설정 목록 (삭제되지 않은 것만) + */ + List findByIsDeletedFalse(); +} + + + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java new file mode 100644 index 00000000..a1a5a12c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java @@ -0,0 +1,159 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse; +import com.agenticcp.core.common.util.LogMaskingUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + + +/** + * 테넌트 설정 캐시 서비스 + * + * 테넌트별 유효 설정을 캐시하여 성능을 최적화하고, + * 설정 변경 시 캐시 무효화를 관리합니다. + * + * 캐시 전략: + * - tenantConfigs: 테넌트의 전체 설정 캐시 + * - tenantConfig: 테넌트의 개별 설정 캐시 + * + * 무효화 시나리오: + * 1. TenantConfig 변경 시 → 해당 테넌트 캐시만 무효화 + * 2. TenantTypeConfig 변경 시 → 해당 타입의 모든 테넌트 캐시 무효화 + * 3. PlatformConfig 변경 시 → 전체 캐시 무효화 + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TenantConfigCacheService { + + private final TenantConfigInheritanceService tenantConfigInheritanceService; + + /** + * 캐시된 테넌트 전체 설정 조회 + * + * 테넌트의 모든 유효 설정을 캐시에서 조회합니다. + * 캐시 미스 시 TenantConfigInheritanceService를 통해 설정을 계산하고 캐시합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @return 유효 설정 응답 (설정값과 출처 정보 포함) + */ + @Cacheable(value = "tenantConfigs", key = "#tenantKey") + public EffectiveConfigResponse getCachedConfigurations(String tenantKey) { + log.info("[TenantConfigCacheService] getCachedConfigurations - cache miss, loading tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + return tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey); + } + + /** + * 캐시된 특정 설정 조회 + * + * 테넌트의 특정 설정 키에 대한 값을 캐시에서 조회합니다. + * 캐시 미스 시 상속 계층을 통해 설정을 계산하고 캐시합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @param configKey 조회할 설정 키 + * @return 유효 설정값 (파싱된 객체), 없으면 null + */ + @Cacheable(value = "tenantConfig", key = "#tenantKey + ':' + #configKey") + public Object getCachedConfiguration(String tenantKey, String configKey) { + log.info("[TenantConfigCacheService] getCachedConfiguration - cache miss, loading tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + return tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, configKey); + } + + /** + * 특정 테넌트의 설정 캐시 무효화 + * + * 테넌트의 개별 설정(TenantConfig)이 변경되었을 때 호출됩니다. + * 해당 테넌트의 전체 설정 캐시와 개별 설정 캐시를 모두 무효화합니다. + * + * @param tenantKey 캐시를 무효화할 테넌트 키 + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, key = "#tenantKey") + public void evictTenantConfigCache(String tenantKey) { + log.info("[TenantConfigCacheService] evictTenantConfigCache - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + } + + /** + * 특정 테넌트의 특정 설정 캐시 무효화 + * + * 테넌트의 개별 설정 중 특정 키만 변경되었을 때 호출됩니다. + * 해당 테넌트의 전체 설정 캐시와 해당 키의 개별 캐시를 무효화합니다. + * + * @param tenantKey 캐시를 무효화할 테넌트 키 + * @param configKey 캐시를 무효화할 설정 키 + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, key = "#tenantKey + ':' + #configKey") + public void evictTenantConfigCache(String tenantKey, String configKey) { + log.info("[TenantConfigCacheService] evictTenantConfigCache - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + } + + /** + * 모든 테넌트 설정 캐시 일괄 무효화 + * + * 모든 테넌트의 설정 캐시를 일괄 무효화합니다. + * 시스템 전반에 영향을 주는 변경 시 사용됩니다. + * (예: 대규모 설정 마이그레이션, 캐시 초기화) + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true) + public void evictAllTenantConfigCache() { + log.info("[TenantConfigCacheService] evictAllTenantConfigCache"); + } + + /** + * 테넌트 타입별 캐시 무효화 + * + * TenantTypeConfig(타입별 기본 설정)가 변경되었을 때 호출됩니다. + * 해당 타입을 사용하는 모든 테넌트의 캐시를 무효화합니다. + * (예: ENTERPRISE 타입의 기본 설정 변경 시) + * + * 참고: 현재는 allEntries=true로 전체 무효화하지만, + * 향후 성능 최적화를 위해 특정 타입만 무효화하도록 개선 가능 + * + * @param tenantType 캐시를 무효화할 테넌트 타입 + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true) + public void evictCacheByTenantType(String tenantType) { + log.info("[TenantConfigCacheService] evictCacheByTenantType - tenantType={}", tenantType); + } + + /** + * 플랫폼 설정 변경으로 인한 캐시 무효화 + * + * PlatformConfig(플랫폼 전역 설정)가 변경되었을 때 호출됩니다. + * 플랫폼 설정은 모든 테넌트에 영향을 주므로 전체 캐시를 무효화합니다. + * (예: 전역 기본값, 시스템 레벨 제한 변경 시) + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true) + public void evictCacheByPlatformConfigChange() { + log.info("[TenantConfigCacheService] evictCacheByPlatformConfigChange"); + } + + /** + * 특정 설정 키 변경으로 인한 캐시 무효화 + * + * 특정 설정 키가 여러 레벨(Platform, TenantType, Tenant)에서 변경되었을 때 호출됩니다. + * 해당 설정 키를 참조하는 모든 테넌트의 캐시를 무효화합니다. + * (예: max_users 설정이 여러 레벨에서 변경될 때) + * + * 참고: 현재는 allEntries=true로 전체 무효화하지만, + * 향후 성능 최적화를 위해 특정 키만 무효화하도록 개선 가능 + * + * @param configKey 변경된 설정 키 + */ + @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true) + public void evictCacheByConfigKey(String configKey) { + log.info("[TenantConfigCacheService] evictCacheByConfigKey - configKey={}", configKey); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java new file mode 100644 index 00000000..6c8bcad2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java @@ -0,0 +1,351 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.domain.platform.entity.PlatformConfig; +import com.agenticcp.core.domain.platform.service.PlatformConfigService; +import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig; +import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository; +import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository; +import com.agenticcp.core.common.util.LogMaskingUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 테넌트 설정 상속 서비스 + * + * 플랫폼 전역 설정 → 테넌트 타입 기본 설정 → 개별 테넌트 설정 순서로 상속하여 + * 테넌트별 유효 설정을 계산합니다. + * + * 설정 우선순위: + * 1. TenantConfig (개별 테넌트 설정) - 최우선 + * 2. TenantTypeConfig (테넌트 타입별 기본 설정) - 중간 + * 3. PlatformConfig (플랫폼 전역 설정) - 최하위 + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TenantConfigInheritanceService { + + private final PlatformConfigService platformConfigService; + private final TenantService tenantService; + private final TenantConfigRepository tenantConfigRepository; + private final TenantTypeConfigRepository tenantTypeConfigRepository; + + /** + * 테넌트의 모든 유효 설정 조회 + * + * 플랫폼, 테넌트 타입, 개별 테넌트의 설정을 계층적으로 병합하여 + * 최종 유효 설정값과 출처 정보를 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @return 유효 설정 응답 (설정값과 출처 정보 포함) + */ + public EffectiveConfigResponse getEffectiveConfigurations(String tenantKey) { + log.info("[TenantConfigInheritanceService] getEffectiveConfigurations - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey); + Map configurations = new HashMap<>(); + + // 설정 상속 프로세스 (하위 레벨이 상위 레벨을 오버라이드) + // 1. 플랫폼 전역 설정 로드 (기본값) + loadPlatformConfigurations(configurations); + + // 2. 테넌트 타입별 기본 설정 적용 (플랫폼 설정 오버라이드) + // 예: ENTERPRISE 플랜의 기본 스펙 적용 + loadTenantTypeConfigurations(tenant.getTenantType(), configurations); + + // 3. 개별 테넌트 설정 적용 (최종 오버라이드) + // 예: 특별 계약 고객의 맞춤 설정 + loadTenantConfigurations(tenant, configurations); + + EffectiveConfigResponse response = EffectiveConfigResponse.builder() + .tenantKey(tenantKey) + .configurations(configurations) + .build(); + + log.info("[TenantConfigInheritanceService] getEffectiveConfigurations - success tenantKey={}, configCount={}", + LogMaskingUtils.maskTenantKey(tenantKey), configurations.size()); + + return response; + } + + /** + * 테넌트의 특정 설정값 조회 + * + * 상속 순서(개별 테넌트 → 테넌트 타입 → 플랫폼)에 따라 + * 가장 우선순위가 높은 설정값을 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @param configKey 조회할 설정 키 + * @return 유효 설정값 (파싱된 객체), 없으면 null + */ + public Object getEffectiveConfiguration(String tenantKey, String configKey) { + log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey); + + // 1. 개별 테넌트 설정 확인 (최우선) + Optional tenantConfig = tenantConfigRepository + .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, configKey); + if (tenantConfig.isPresent()) { + log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in tenant config"); + return parseConfigValue(tenantConfig.get().getConfigValue(), tenantConfig.get().getConfigType()); + } + + // 2. 테넌트 타입별 설정 확인 + Optional typeConfig = tenantTypeConfigRepository + .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenant.getTenantType(), configKey); + if (typeConfig.isPresent()) { + log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in tenant type config"); + return parseConfigValue(typeConfig.get().getConfigValue(), typeConfig.get().getConfigType()); + } + + // 3. 플랫폼 전역 설정 확인 + Optional platformConfig = platformConfigService.getConfigByKey(configKey); + if (platformConfig.isPresent()) { + log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in platform config"); + return parseConfigValue(platformConfig.get().getConfigValue(), platformConfig.get().getConfigType()); + } + + log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - not found tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + return null; + } + + /** + * 플랫폼 전역 설정 로드 + * + * 플랫폼 전체에 적용되는 기본 설정을 맵에 추가합니다. + * + * @param configurations 설정을 저장할 맵 + */ + private void loadPlatformConfigurations(Map configurations) { + List platformConfigs = platformConfigService.getAllConfigs(); + for (PlatformConfig config : platformConfigs) { + configurations.put(config.getConfigKey(), + EffectiveConfigResponse.ConfigValueWithSource.builder() + .value(parseConfigValue(config.getConfigValue(), config.getConfigType())) + .source(EffectiveConfigResponse.ConfigSource.PLATFORM) + .description(config.getDescription()) + .configType(mapConfigType(config.getConfigType())) + .build()); + } + } + + /** + * 테넌트 타입별 설정 로드 (오버라이드) + * + * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)에 해당하는 기본 설정을 로드하여 + * 플랫폼 설정을 오버라이드합니다. + * + * @param tenantType 테넌트 타입 + * @param configurations 설정을 저장할 맵 (기존 값 오버라이드) + */ + private void loadTenantTypeConfigurations(Tenant.TenantType tenantType, + Map configurations) { + List typeConfigs = tenantTypeConfigRepository + .findByTenantTypeAndIsDeletedFalse(tenantType); + + for (TenantTypeConfig config : typeConfigs) { + configurations.put(config.getConfigKey(), + EffectiveConfigResponse.ConfigValueWithSource.builder() + .value(parseConfigValue(config.getConfigValue(), config.getConfigType())) + .source(EffectiveConfigResponse.ConfigSource.TENANT_TYPE) + .description(config.getDescription()) + .configType(mapConfigType(config.getConfigType())) + .build()); + } + } + + /** + * 개별 테넌트 설정 로드 (최종 오버라이드) + * + * 특정 테넌트만의 고유 설정을 로드하여 + * 플랫폼 및 타입 설정을 최종 오버라이드합니다. + * + * @param tenant 테넌트 엔티티 + * @param configurations 설정을 저장할 맵 (최종 오버라이드) + */ + private void loadTenantConfigurations(Tenant tenant, + Map configurations) { + List tenantConfigs = tenantConfigRepository + .findByTenantAndIsDeletedFalse(tenant); + + for (TenantConfig config : tenantConfigs) { + configurations.put(config.getConfigKey(), + EffectiveConfigResponse.ConfigValueWithSource.builder() + .value(parseConfigValue(config.getConfigValue(), config.getConfigType())) + .source(EffectiveConfigResponse.ConfigSource.TENANT) + .description(config.getDescription()) + .configType(config.getConfigType()) + .build()); + } + } + + /** + * 설정값 파싱 (타입 다형성 처리) + * + * 다양한 ConfigType(TenantConfig, TenantTypeConfig, PlatformConfig)을 + * 처리하기 위한 오버로딩 메서드 디스패처입니다. + * + * @param configValue 파싱할 설정값 문자열 + * @param configType 설정 타입 (다형성) + * @return 파싱된 객체 (String, Long, Double, Boolean 등) + */ + private Object parseConfigValue(String configValue, Object configType) { + if (configValue == null) { + return null; + } + + if (configType instanceof TenantConfig.ConfigType) { + return parseConfigValue(configValue, (TenantConfig.ConfigType) configType); + } else if (configType instanceof TenantTypeConfig.ConfigType) { + return parseConfigValue(configValue, (TenantTypeConfig.ConfigType) configType); + } else if (configType instanceof PlatformConfig.ConfigType) { + return parseConfigValue(configValue, (PlatformConfig.ConfigType) configType); + } + + return configValue; + } + + /** + * TenantConfig.ConfigType 기반 설정값 파싱 + * + * @param configValue 파싱할 설정값 + * @param configType 테넌트 설정 타입 + * @return 파싱된 객체 + */ + private Object parseConfigValue(String configValue, TenantConfig.ConfigType configType) { + return parseConfigValueByType(configValue, configType.name()); + } + + /** + * TenantTypeConfig.ConfigType 기반 설정값 파싱 + * + * @param configValue 파싱할 설정값 + * @param configType 테넌트 타입 설정 타입 + * @return 파싱된 객체 + */ + private Object parseConfigValue(String configValue, TenantTypeConfig.ConfigType configType) { + return parseConfigValueByType(configValue, configType.name()); + } + + /** + * PlatformConfig.ConfigType 기반 설정값 파싱 + * + * @param configValue 파싱할 설정값 + * @param configType 플랫폼 설정 타입 + * @return 파싱된 객체 + */ + private Object parseConfigValue(String configValue, PlatformConfig.ConfigType configType) { + return parseConfigValueByType(configValue, configType.name()); + } + + /** + * 타입 이름 기반 설정값 파싱 + * + * 타입에 따라 문자열을 적절한 객체로 변환합니다: + * - NUMBER: 소수점 있으면 Double, 없으면 Long + * - BOOLEAN: Boolean + * - JSON: String (그대로 반환, 필요 시 클라이언트에서 파싱) + * - 기타: String + * + * @param configValue 파싱할 설정값 + * @param typeName 타입 이름 (STRING, NUMBER, BOOLEAN, JSON, ENCRYPTED) + * @return 파싱된 객체, 파싱 실패 시 원본 문자열 반환 + */ + private Object parseConfigValueByType(String configValue, String typeName) { + try { + switch (typeName) { + case "NUMBER": + // 숫자 타입: 소수점 포함 여부로 Double/Long 구분 + if (configValue.contains(".")) { + return Double.parseDouble(configValue); // 실수 + } else { + return Long.parseLong(configValue); // 정수 + } + case "BOOLEAN": + // 불리언 타입: "true", "false" 문자열을 Boolean으로 변환 + return Boolean.parseBoolean(configValue); + case "JSON": + // JSON 타입: 문자열로 반환 (클라이언트에서 필요 시 파싱) + // 향후 ObjectMapper를 사용한 검증 추가 고려 + return configValue; + default: + // STRING, ENCRYPTED 등: 문자열 그대로 반환 + return configValue; + } + } catch (NumberFormatException e) { + // 파싱 실패 시 경고 로그 후 원본 문자열 반환 (시스템 중단 방지) + log.warn("[TenantConfigInheritanceService] 설정값 파싱 실패 - value={}, type={}, error={}", + configValue, typeName, e.getMessage()); + return configValue; + } + } + + /** + * TenantTypeConfig.ConfigType을 TenantConfig.ConfigType으로 매핑 + * + * 응답 DTO에서 일관된 타입을 사용하기 위한 매핑 메서드입니다. + * + * @param typeConfigType 테넌트 타입 설정 타입 + * @return 테넌트 설정 타입 + */ + private TenantConfig.ConfigType mapConfigType(TenantTypeConfig.ConfigType typeConfigType) { + switch (typeConfigType) { + case STRING: + return TenantConfig.ConfigType.STRING; + case NUMBER: + return TenantConfig.ConfigType.NUMBER; + case BOOLEAN: + return TenantConfig.ConfigType.BOOLEAN; + case JSON: + return TenantConfig.ConfigType.JSON; + case ENCRYPTED: + return TenantConfig.ConfigType.ENCRYPTED; + default: + return TenantConfig.ConfigType.STRING; + } + } + + /** + * PlatformConfig.ConfigType을 TenantConfig.ConfigType으로 매핑 + * + * 응답 DTO에서 일관된 타입을 사용하기 위한 매핑 메서드입니다. + * + * @param platformType 플랫폼 설정 타입 + * @return 테넌트 설정 타입 + */ + private TenantConfig.ConfigType mapConfigType(PlatformConfig.ConfigType platformType) { + switch (platformType) { + case STRING: + return TenantConfig.ConfigType.STRING; + case NUMBER: + return TenantConfig.ConfigType.NUMBER; + case BOOLEAN: + return TenantConfig.ConfigType.BOOLEAN; + case JSON: + return TenantConfig.ConfigType.JSON; + case ENCRYPTED: + return TenantConfig.ConfigType.ENCRYPTED; + default: + return TenantConfig.ConfigType.STRING; + } + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java new file mode 100644 index 00000000..bfad2b04 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java @@ -0,0 +1,200 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository; +import com.agenticcp.core.common.util.LogMaskingUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 테넌트 설정 관리 서비스 + * + * 개별 테넌트의 설정을 생성/수정/삭제/조회하는 기능을 제공합니다. + * TenantConfig는 특정 테넌트만의 고유 설정으로, + * TenantTypeConfig와 PlatformConfig보다 높은 우선순위를 가집니다. + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TenantConfigService { + + private final TenantConfigRepository tenantConfigRepository; + private final TenantService tenantService; + + /** + * 테넌트의 모든 설정 조회 + * + * 특정 테넌트에 설정된 모든 개별 설정을 조회합니다. + * 삭제되지 않은(isDeleted=false) 설정만 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @return 테넌트의 설정 목록 + */ + public List getTenantConfigurations(String tenantKey) { + log.info("[TenantConfigService] getTenantConfigurations - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + List configs = tenantConfigRepository.findByTenantKey(tenantKey); + + log.info("[TenantConfigService] getTenantConfigurations - success tenantKey={}, count={}", + LogMaskingUtils.maskTenantKey(tenantKey), configs.size()); + + return configs; + } + + /** + * 테넌트의 특정 설정 조회 + * + * 테넌트의 특정 설정 키에 해당하는 설정을 조회합니다. + * 설정이 없거나 삭제된 경우 Empty Optional을 반환합니다. + * + * @param tenantKey 조회할 테넌트 키 + * @param configKey 조회할 설정 키 + * @return Optional로 감싼 테넌트 설정 (없으면 Empty) + */ + public Optional getTenantConfiguration(String tenantKey, String configKey) { + log.info("[TenantConfigService] getTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + Optional config = tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, configKey); + + log.info("[TenantConfigService] getTenantConfiguration - found={}, tenantKey={}, configKey={}", + config.isPresent(), LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + return config; + } + + /** + * 테넌트 설정 생성/수정 + * + * 테넌트의 특정 설정을 생성하거나 수정합니다. + * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다. + * 저장 후 해당 테넌트의 설정 캐시를 무효화합니다. + * + * @param tenantKey 설정을 저장할 테넌트 키 + * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부) + * @return 저장된 테넌트 설정 + */ + @Transactional + @CacheEvict(value = "tenantConfigs", key = "#tenantKey") + public TenantConfig saveTenantConfiguration(String tenantKey, TenantConfigRequest request) { + log.info("[TenantConfigService] saveTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey()); + + Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey); + + // Upsert 패턴: 기존 설정 존재 여부 확인 + Optional existingConfig = tenantConfigRepository + .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, request.getConfigKey()); + + TenantConfig config; + if (existingConfig.isPresent()) { + // 기존 설정 수정 (모든 필드 업데이트) + config = existingConfig.get(); + config.setConfigValue(request.getConfigValue()); + config.setConfigType(request.getConfigType()); + config.setDescription(request.getDescription()); + config.setIsEncrypted(request.getIsEncrypted()); + log.info("[TenantConfigService] saveTenantConfiguration - updated existing config"); + } else { + // 신규 설정 생성 + config = TenantConfig.builder() + .tenant(tenant) + .configKey(request.getConfigKey()) + .configValue(request.getConfigValue()) + .configType(request.getConfigType()) + .description(request.getDescription()) + .isEncrypted(request.getIsEncrypted()) + .build(); + log.info("[TenantConfigService] saveTenantConfiguration - created new config"); + } + + TenantConfig savedConfig = tenantConfigRepository.save(config); + + log.info("[TenantConfigService] saveTenantConfiguration - success tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey()); + + return savedConfig; + } + + /** + * 테넌트 설정 삭제 (소프트 삭제) + * + * 특정 테넌트의 설정을 삭제합니다. + * 물리적 삭제가 아닌 isDeleted 플래그를 true로 변경하는 소프트 삭제입니다. + * 삭제 후 해당 테넌트의 설정 캐시를 무효화합니다. + * + * @param tenantKey 삭제할 설정이 속한 테넌트 키 + * @param configKey 삭제할 설정 키 + * @throws ResourceNotFoundException 설정을 찾을 수 없는 경우 + */ + @Transactional + @CacheEvict(value = "tenantConfigs", key = "#tenantKey") + public void deleteTenantConfiguration(String tenantKey, String configKey) { + log.info("[TenantConfigService] deleteTenantConfiguration - tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + + Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey); + + // 설정 존재 확인 후 소프트 삭제 + TenantConfig config = tenantConfigRepository + .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, configKey) + .orElseThrow(() -> new ResourceNotFoundException("TenantConfig", "configKey", configKey)); + + // 소프트 삭제: isDeleted 플래그만 변경 (데이터 보존) + config.setIsDeleted(true); + tenantConfigRepository.save(config); + + log.info("[TenantConfigService] deleteTenantConfiguration - success tenantKey={}, configKey={}", + LogMaskingUtils.maskTenantKey(tenantKey), configKey); + } + + /** + * 테넌트의 모든 설정 일괄 삭제 (소프트 삭제) + * + * 특정 테넌트의 모든 개별 설정을 일괄 삭제합니다. + * 소프트 삭제 방식으로 데이터는 보존되며, 삭제 후 캐시를 무효화합니다. + * 테넌트 탈퇴나 초기화 시 사용됩니다. + * + * @param tenantKey 모든 설정을 삭제할 테넌트 키 + */ + @Transactional + @CacheEvict(value = "tenantConfigs", key = "#tenantKey") + public void deleteAllTenantConfigurations(String tenantKey) { + log.info("[TenantConfigService] deleteAllTenantConfigurations - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenantKey)); + + Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey); + + // 삭제되지 않은 모든 설정 조회 + List configs = tenantConfigRepository.findByTenantAndIsDeletedFalse(tenant); + + // 일괄 소프트 삭제 처리 + for (TenantConfig config : configs) { + config.setIsDeleted(true); + } + + // 배치 업데이트로 성능 최적화 + tenantConfigRepository.saveAll(configs); + + log.info("[TenantConfigService] deleteAllTenantConfigurations - success tenantKey={}, count={}", + LogMaskingUtils.maskTenantKey(tenantKey), configs.size()); + } +} + + + diff --git a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java index 944cfd8d..d82d128f 100644 --- a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java +++ b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java @@ -10,7 +10,16 @@ @SpringBootTest(properties = { "app.redis.enabled=false", - "spring.cache.type=simple" + "spring.cache.type=simple", + "spring.datasource.url=jdbc:h2:mem:testdb", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "logging.level.org.springframework.web=WARN", + "logging.level.org.hibernate=WARN", + "logging.level.org.springframework.boot.autoconfigure=WARN", + "logging.level.org.springframework.context=WARN", + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.redis.RedisAutoConfiguration" }) @ActiveProfiles("test") @Import(MockAdaptersConfig.class) @@ -28,4 +37,4 @@ void contextLoads() { // 애플리케이션 컨텍스트가 정상적으로 로드되는지 테스트 // Redis가 비활성화된 상태에서도 정상 동작하는지 확인 } -} +} \ No newline at end of file diff --git a/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java b/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java index 59e9ba26..ab816b3c 100644 --- a/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java +++ b/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java @@ -110,6 +110,4 @@ void permissions_Called_Twice_ShouldOk() throws Exception { .andExpect(status().isOk()); } } -} - - +} \ No newline at end of file diff --git a/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java new file mode 100644 index 00000000..8c19677b --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java @@ -0,0 +1,249 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.domain.platform.entity.PlatformConfig; +import com.agenticcp.core.domain.platform.service.PlatformConfigService; +import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig; +import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository; +import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * 테넌트 설정 상속 서비스 테스트 + */ +@ExtendWith(MockitoExtension.class) +class TenantConfigInheritanceServiceTest { + + @Mock + private PlatformConfigService platformConfigService; + + @Mock + private TenantService tenantService; + + @Mock + private TenantConfigRepository tenantConfigRepository; + + @Mock + private TenantTypeConfigRepository tenantTypeConfigRepository; + + @InjectMocks + private TenantConfigInheritanceService tenantConfigInheritanceService; + + private Tenant testTenant; + private String tenantKey = "test-tenant"; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey(tenantKey) + .tenantName("Test Tenant") + .tenantType(Tenant.TenantType.ENTERPRISE) + .build(); + } + + @Test + void getEffectiveConfigurations_플랫폼설정만있는경우() { + // Given + PlatformConfig platformConfig = PlatformConfig.builder() + .configKey("max_file_size") + .configValue("100") + .configType(PlatformConfig.ConfigType.NUMBER) + .description("Maximum file size in MB") + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig)); + when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE)) + .thenReturn(List.of()); + when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(List.of()); + + // When + EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey); + + // Then + assertThat(response.getTenantKey()).isEqualTo(tenantKey); + assertThat(response.getConfigurations()).hasSize(1); + assertThat(response.getConfigurations().get("max_file_size")).isNotNull(); + assertThat(response.getConfigurations().get("max_file_size").getValue()).isEqualTo(100L); + assertThat(response.getConfigurations().get("max_file_size").getSource()) + .isEqualTo(EffectiveConfigResponse.ConfigSource.PLATFORM); + } + + @Test + void getEffectiveConfigurations_테넌트타입설정오버라이드() { + // Given + PlatformConfig platformConfig = PlatformConfig.builder() + .configKey("max_users") + .configValue("50") + .configType(PlatformConfig.ConfigType.NUMBER) + .description("Default max users") + .build(); + + TenantTypeConfig typeConfig = TenantTypeConfig.builder() + .tenantType(Tenant.TenantType.ENTERPRISE) + .configKey("max_users") + .configValue("1000") + .configType(TenantTypeConfig.ConfigType.NUMBER) + .description("Enterprise max users") + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig)); + when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE)) + .thenReturn(List.of(typeConfig)); + when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(List.of()); + + // When + EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey); + + // Then + assertThat(response.getConfigurations().get("max_users").getValue()).isEqualTo(1000L); + assertThat(response.getConfigurations().get("max_users").getSource()) + .isEqualTo(EffectiveConfigResponse.ConfigSource.TENANT_TYPE); + } + + @Test + void getEffectiveConfigurations_테넌트설정최종오버라이드() { + // Given + PlatformConfig platformConfig = PlatformConfig.builder() + .configKey("max_users") + .configValue("50") + .configType(PlatformConfig.ConfigType.NUMBER) + .build(); + + TenantTypeConfig typeConfig = TenantTypeConfig.builder() + .tenantType(Tenant.TenantType.ENTERPRISE) + .configKey("max_users") + .configValue("1000") + .configType(TenantTypeConfig.ConfigType.NUMBER) + .build(); + + TenantConfig tenantConfig = TenantConfig.builder() + .tenant(testTenant) + .configKey("max_users") + .configValue("2000") + .configType(TenantConfig.ConfigType.NUMBER) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig)); + when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE)) + .thenReturn(List.of(typeConfig)); + when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(List.of(tenantConfig)); + + // When + EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey); + + // Then + assertThat(response.getConfigurations().get("max_users").getValue()).isEqualTo(2000L); + assertThat(response.getConfigurations().get("max_users").getSource()) + .isEqualTo(EffectiveConfigResponse.ConfigSource.TENANT); + } + + @Test + void getEffectiveConfiguration_개별설정조회() { + // Given + TenantConfig tenantConfig = TenantConfig.builder() + .tenant(testTenant) + .configKey("custom_setting") + .configValue("custom_value") + .configType(TenantConfig.ConfigType.STRING) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "custom_setting")) + .thenReturn(Optional.of(tenantConfig)); + + // When + Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "custom_setting"); + + // Then + assertThat(value).isEqualTo("custom_value"); + } + + @Test + void getEffectiveConfiguration_타입설정조회() { + // Given + TenantTypeConfig typeConfig = TenantTypeConfig.builder() + .tenantType(Tenant.TenantType.ENTERPRISE) + .configKey("type_setting") + .configValue("type_value") + .configType(TenantTypeConfig.ConfigType.STRING) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "type_setting")) + .thenReturn(Optional.empty()); + when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "type_setting")) + .thenReturn(Optional.of(typeConfig)); + + // When + Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "type_setting"); + + // Then + assertThat(value).isEqualTo("type_value"); + } + + @Test + void getEffectiveConfiguration_플랫폼설정조회() { + // Given + PlatformConfig platformConfig = PlatformConfig.builder() + .configKey("platform_setting") + .configValue("platform_value") + .configType(PlatformConfig.ConfigType.STRING) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "platform_setting")) + .thenReturn(Optional.empty()); + when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "platform_setting")) + .thenReturn(Optional.empty()); + when(platformConfigService.getConfigByKey("platform_setting")) + .thenReturn(Optional.of(platformConfig)); + + // When + Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "platform_setting"); + + // Then + assertThat(value).isEqualTo("platform_value"); + } + + @Test + void getEffectiveConfiguration_설정이없는경우() { + // Given + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "non_existent")) + .thenReturn(Optional.empty()); + when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "non_existent")) + .thenReturn(Optional.empty()); + when(platformConfigService.getConfigByKey("non_existent")) + .thenReturn(Optional.empty()); + + // When + Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "non_existent"); + + // Then + assertThat(value).isNull(); + } +} + + + diff --git a/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java new file mode 100644 index 00000000..ea07745f --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java @@ -0,0 +1,243 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantConfig; +import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * 테넌트 설정 서비스 테스트 + */ +@ExtendWith(MockitoExtension.class) +class TenantConfigServiceTest { + + @Mock + private TenantConfigRepository tenantConfigRepository; + + @Mock + private TenantService tenantService; + + @InjectMocks + private TenantConfigService tenantConfigService; + + private Tenant testTenant; + private String tenantKey = "test-tenant"; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey(tenantKey) + .tenantName("Test Tenant") + .tenantType(Tenant.TenantType.ENTERPRISE) + .build(); + } + + @Test + void getTenantConfigurations_성공() { + // Given + TenantConfig config1 = TenantConfig.builder() + .configKey("setting1") + .configValue("value1") + .configType(TenantConfig.ConfigType.STRING) + .build(); + + TenantConfig config2 = TenantConfig.builder() + .configKey("setting2") + .configValue("value2") + .configType(TenantConfig.ConfigType.NUMBER) + .build(); + + when(tenantConfigRepository.findByTenantKey(tenantKey)) + .thenReturn(List.of(config1, config2)); + + // When + List result = tenantConfigService.getTenantConfigurations(tenantKey); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getConfigKey()).isEqualTo("setting1"); + assertThat(result.get(1).getConfigKey()).isEqualTo("setting2"); + } + + @Test + void getTenantConfiguration_존재하는설정() { + // Given + TenantConfig config = TenantConfig.builder() + .configKey("setting1") + .configValue("value1") + .configType(TenantConfig.ConfigType.STRING) + .build(); + + when(tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, "setting1")) + .thenReturn(Optional.of(config)); + + // When + Optional result = tenantConfigService.getTenantConfiguration(tenantKey, "setting1"); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getConfigKey()).isEqualTo("setting1"); + } + + @Test + void getTenantConfiguration_존재하지않는설정() { + // Given + when(tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, "non_existent")) + .thenReturn(Optional.empty()); + + // When + Optional result = tenantConfigService.getTenantConfiguration(tenantKey, "non_existent"); + + // Then + assertThat(result).isEmpty(); + } + + @Test + void saveTenantConfiguration_새설정생성() { + // Given + TenantConfigRequest request = TenantConfigRequest.builder() + .configKey("new_setting") + .configValue("new_value") + .configType(TenantConfig.ConfigType.STRING) + .description("New setting") + .isEncrypted(false) + .build(); + + TenantConfig savedConfig = TenantConfig.builder() + .tenant(testTenant) + .configKey("new_setting") + .configValue("new_value") + .configType(TenantConfig.ConfigType.STRING) + .description("New setting") + .isEncrypted(false) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "new_setting")) + .thenReturn(Optional.empty()); + when(tenantConfigRepository.save(any(TenantConfig.class))).thenReturn(savedConfig); + + // When + TenantConfig result = tenantConfigService.saveTenantConfiguration(tenantKey, request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getConfigKey()).isEqualTo("new_setting"); + assertThat(result.getConfigValue()).isEqualTo("new_value"); + } + + @Test + void saveTenantConfiguration_기존설정수정() { + // Given + TenantConfigRequest request = TenantConfigRequest.builder() + .configKey("existing_setting") + .configValue("updated_value") + .configType(TenantConfig.ConfigType.STRING) + .description("Updated setting") + .isEncrypted(false) + .build(); + + TenantConfig existingConfig = TenantConfig.builder() + .tenant(testTenant) + .configKey("existing_setting") + .configValue("old_value") + .configType(TenantConfig.ConfigType.STRING) + .description("Old setting") + .isEncrypted(false) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "existing_setting")) + .thenReturn(Optional.of(existingConfig)); + when(tenantConfigRepository.save(existingConfig)).thenReturn(existingConfig); + + // When + TenantConfig result = tenantConfigService.saveTenantConfiguration(tenantKey, request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getConfigValue()).isEqualTo("updated_value"); + assertThat(result.getDescription()).isEqualTo("Updated setting"); + } + + @Test + void deleteTenantConfiguration_성공() { + // Given + TenantConfig config = TenantConfig.builder() + .tenant(testTenant) + .configKey("setting_to_delete") + .configValue("value") + .configType(TenantConfig.ConfigType.STRING) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "setting_to_delete")) + .thenReturn(Optional.of(config)); + when(tenantConfigRepository.save(config)).thenReturn(config); + + // When + tenantConfigService.deleteTenantConfiguration(tenantKey, "setting_to_delete"); + + // Then + assertThat(config.getIsDeleted()).isTrue(); + } + + @Test + void deleteTenantConfiguration_존재하지않는설정() { + // Given + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "non_existent")) + .thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> tenantConfigService.deleteTenantConfiguration(tenantKey, "non_existent")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void deleteAllTenantConfigurations_성공() { + // Given + TenantConfig config1 = TenantConfig.builder() + .tenant(testTenant) + .configKey("setting1") + .configValue("value1") + .configType(TenantConfig.ConfigType.STRING) + .build(); + + TenantConfig config2 = TenantConfig.builder() + .tenant(testTenant) + .configKey("setting2") + .configValue("value2") + .configType(TenantConfig.ConfigType.NUMBER) + .build(); + + when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant); + when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(List.of(config1, config2)); + when(tenantConfigRepository.saveAll(List.of(config1, config2))) + .thenReturn(List.of(config1, config2)); + + // When + tenantConfigService.deleteAllTenantConfigurations(tenantKey); + + // Then + assertThat(config1.getIsDeleted()).isTrue(); + assertThat(config2.getIsDeleted()).isTrue(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 68b07f87..742d4003 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,13 +2,13 @@ spring: autoconfigure: exclude: - org.redisson.spring.starter.RedissonAutoConfiguration - + datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL driver-class-name: org.h2.Driver username: sa - password: - + password: + jpa: hibernate: ddl-auto: create-drop @@ -16,14 +16,14 @@ spring: properties: hibernate: format_sql: false - + cache: type: simple - + redis: host: ${SPRING_REDIS_HOST:localhost} port: ${SPRING_REDIS_PORT:6379} - password: + password: timeout: 2000ms lettuce: pool: