Merge pull request 'Sync main → gbpark-node: AI 모듈 JSONB 파싱 + audit-log fix' (#1) from main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-02 10:20:35 +00:00
214 changed files with 9738 additions and 10728 deletions
+4
View File
@@ -34,6 +34,10 @@ dependencies {
implementation 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springframework.boot:spring-boot-starter-quartz'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
@@ -1,5 +1,6 @@
package com.erp;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@@ -8,6 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.erp.ai.mapper")
public class ErpApplication {
public static void main(String[] args) {
SpringApplication.run(ErpApplication.class, args);
@@ -0,0 +1,133 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Anthropic Claude LLM 클라이언트.
* /v1/messages 엔드포인트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AnthropicLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
private static final String ANTHROPIC_VERSION = "2023-06-01";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && model.startsWith("claude-");
}
@Override
public String providerName() {
return "anthropic";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("anthropic");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("Anthropic 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
// OpenAI -> Anthropic 메시지 매핑
List<Map<String, Object>> openaiMsgs = (List<Map<String, Object>>) request.getOrDefault("messages", List.of());
String systemPrompt = openaiMsgs.stream()
.filter(m -> "system".equals(m.get("role")))
.map(m -> String.valueOf(m.get("content")))
.findFirst().orElse(null);
List<Map<String, Object>> anthropicMsgs = openaiMsgs.stream()
.filter(m -> !"system".equals(m.get("role")))
.map(m -> Map.of("role", String.valueOf(m.get("role")), "content", m.get("content")))
.toList();
Map<String, Object> body = new HashMap<>();
body.put("model", request.get("model"));
body.put("messages", anthropicMsgs);
if (systemPrompt != null) body.put("system", systemPrompt);
body.put("max_tokens", request.getOrDefault("max_tokens", 2000));
if (request.get("temperature") != null) body.put("temperature", request.get("temperature"));
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/v1/messages")
.contentType(MediaType.APPLICATION_JSON)
.header("x-api-key", apiKey)
.header("anthropic-version", ANTHROPIC_VERSION)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, String.valueOf(request.get("model")));
} catch (RestClientResponseException e) {
log.error("Anthropic API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Anthropic API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Anthropic 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> anthropic, String model) {
if (anthropic == null) return Map.of();
// Anthropic content: [{type:"text", text:"..."}]
String text = "";
Object content = anthropic.get("content");
if (content instanceof List<?> list && !list.isEmpty()) {
Object first = list.get(0);
if (first instanceof Map<?, ?> mapItem) {
Object t = mapItem.get("text");
if (t != null) text = String.valueOf(t);
}
}
Map<String, Object> usageOut = new HashMap<>();
Map<String, Object> usage = (Map<String, Object>) anthropic.getOrDefault("usage", Map.of());
Number prompt = (Number) usage.getOrDefault("input_tokens", 0);
Number completion = (Number) usage.getOrDefault("output_tokens", 0);
usageOut.put("prompt_tokens", prompt.intValue());
usageOut.put("completion_tokens", completion.intValue());
usageOut.put("total_tokens", prompt.intValue() + completion.intValue());
List<Map<String, Object>> choices = new ArrayList<>();
choices.add(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", text),
"finish_reason", anthropic.getOrDefault("stop_reason", "stop")
));
Map<String, Object> out = new HashMap<>();
out.put("id", anthropic.getOrDefault("id", "chatcmpl-" + UUID.randomUUID()));
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", choices);
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,140 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Google Gemini LLM 클라이언트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GeminiLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && model.startsWith("gemini-");
}
@Override
public String providerName() {
return "google";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("google");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("Google 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
String model = String.valueOf(request.get("model"));
// OpenAI messages -> Gemini contents
List<Map<String, Object>> openaiMsgs = (List<Map<String, Object>>) request.getOrDefault("messages", List.of());
List<Map<String, Object>> geminiContents = new ArrayList<>();
StringBuilder systemBuf = new StringBuilder();
for (Map<String, Object> m : openaiMsgs) {
String role = String.valueOf(m.get("role"));
String content = String.valueOf(m.get("content"));
if ("system".equals(role)) {
systemBuf.append(content).append("\n");
} else {
String mapped = "assistant".equals(role) ? "model" : "user";
geminiContents.add(Map.of(
"role", mapped,
"parts", List.of(Map.of("text", content))
));
}
}
Map<String, Object> body = new HashMap<>();
body.put("contents", geminiContents);
if (systemBuf.length() > 0) {
body.put("systemInstruction", Map.of("parts", List.of(Map.of("text", systemBuf.toString()))));
}
Map<String, Object> genConfig = new HashMap<>();
if (request.get("max_tokens") != null) genConfig.put("maxOutputTokens", request.get("max_tokens"));
if (request.get("temperature") != null) genConfig.put("temperature", request.get("temperature"));
if (!genConfig.isEmpty()) body.put("generationConfig", genConfig);
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/v1beta/models/" + model + ":generateContent?key=" + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, model);
} catch (RestClientResponseException e) {
log.error("Gemini API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Gemini API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Gemini 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> gemini, String model) {
if (gemini == null) return Map.of();
String text = "";
List<Map<String, Object>> candidates = (List<Map<String, Object>>) gemini.getOrDefault("candidates", List.of());
if (!candidates.isEmpty()) {
Map<String, Object> first = candidates.get(0);
Map<String, Object> content = (Map<String, Object>) first.get("content");
if (content != null) {
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.getOrDefault("parts", List.of());
if (!parts.isEmpty()) {
Object t = parts.get(0).get("text");
if (t != null) text = String.valueOf(t);
}
}
}
Map<String, Object> usageMeta = (Map<String, Object>) gemini.getOrDefault("usageMetadata", Map.of());
Number prompt = (Number) usageMeta.getOrDefault("promptTokenCount", 0);
Number completion = (Number) usageMeta.getOrDefault("candidatesTokenCount", 0);
Number total = (Number) usageMeta.getOrDefault("totalTokenCount", prompt.intValue() + completion.intValue());
Map<String, Object> usageOut = new HashMap<>();
usageOut.put("prompt_tokens", prompt.intValue());
usageOut.put("completion_tokens", completion.intValue());
usageOut.put("total_tokens", total.intValue());
Map<String, Object> out = new HashMap<>();
out.put("id", "chatcmpl-" + UUID.randomUUID());
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", List.of(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", text),
"finish_reason", "stop")));
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,36 @@
package com.erp.ai.client;
import java.util.List;
import java.util.Map;
/**
* LLM 클라이언트 추상화.
* architecture §9.2.
*/
public interface LlmClient {
/**
* 모델 매칭 (LlmClientFactory 라우팅용).
*/
boolean supports(String model);
/**
* provider name (anthropic / openai / google / deepseek / ollama).
*/
String providerName();
/**
* 동기 채팅 호출. OpenAI 호환 형식 응답.
*
* @param request {model, messages, max_tokens, temperature, ...}
* @return OpenAI 호환 응답 맵 (id, object, choices, usage 등)
*/
Map<String, Object> chat(Map<String, Object> request);
/**
* 사용 가능한 모델 목록.
*/
default List<Map<String, Object>> listModels() {
return List.of();
}
}
@@ -0,0 +1,32 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 모델 이름으로 적절한 LlmClient 구현체 라우팅.
* architecture §9.3.
*/
@Component
@RequiredArgsConstructor
public class LlmClientFactory {
private final List<LlmClient> clients;
public LlmClient pick(String model) {
if (model == null || model.isBlank()) {
throw new LlmClientException("모델이 지정되지 않았습니다.");
}
return clients.stream()
.filter(c -> c.supports(model))
.findFirst()
.orElseThrow(() -> new LlmClientException("지원하지 않는 모델: " + model));
}
public List<LlmClient> all() {
return clients;
}
}
@@ -0,0 +1,117 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Ollama 로컬 LLM 클라이언트.
* /api/chat 엔드포인트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OllamaLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "http://localhost:11434";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
@Override
public boolean supports(String model) {
// Ollama는 사용자 정의 모델명. 하위 호환을 위해 다른 클라이언트가 지원 안하는 경우 fallback.
// 명시적 prefix: "ollama:" 또는 "llama-" / "mistral-" 등
if (model == null) return false;
String lower = model.toLowerCase();
return lower.startsWith("ollama:")
|| lower.startsWith("llama")
|| lower.startsWith("mistral")
|| lower.startsWith("qwen")
|| lower.startsWith("phi");
}
@Override
public String providerName() {
return "ollama";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("ollama");
String baseUrl = provider != null && provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
String model = String.valueOf(request.get("model"));
if (model.startsWith("ollama:")) model = model.substring(7);
Map<String, Object> body = new HashMap<>();
body.put("model", model);
body.put("messages", request.getOrDefault("messages", List.of()));
body.put("stream", false);
Map<String, Object> options = new HashMap<>();
if (request.get("max_tokens") != null) options.put("num_predict", request.get("max_tokens"));
if (request.get("temperature") != null) options.put("temperature", request.get("temperature"));
if (!options.isEmpty()) body.put("options", options);
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/api/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, model);
} catch (RestClientResponseException e) {
log.error("Ollama API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Ollama API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Ollama 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> ollama, String model) {
if (ollama == null) return Map.of();
Map<String, Object> message = (Map<String, Object>) ollama.getOrDefault("message", Map.of());
String content = String.valueOf(message.getOrDefault("content", ""));
Number promptCount = (Number) ollama.getOrDefault("prompt_eval_count", 0);
Number evalCount = (Number) ollama.getOrDefault("eval_count", 0);
Map<String, Object> usageOut = new HashMap<>();
usageOut.put("prompt_tokens", promptCount.intValue());
usageOut.put("completion_tokens", evalCount.intValue());
usageOut.put("total_tokens", promptCount.intValue() + evalCount.intValue());
List<Map<String, Object>> choices = new ArrayList<>();
choices.add(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", content),
"finish_reason", "stop"
));
Map<String, Object> out = new HashMap<>();
out.put("id", "chatcmpl-" + UUID.randomUUID());
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", choices);
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,67 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.Map;
/**
* OpenAI LLM 클라이언트 (DeepSeek 등 OpenAI 호환 프로바이더 포함).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://api.openai.com";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && (model.startsWith("gpt-") || model.startsWith("o1-") || model.startsWith("o3-"));
}
@Override
public String providerName() {
return "openai";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("openai");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("OpenAI 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
try {
return httpClient.post()
.uri(baseUrl + "/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + apiKey)
.body(request)
.retrieve()
.body(Map.class);
} catch (RestClientResponseException e) {
log.error("OpenAI API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("OpenAI API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("OpenAI 호출 실패: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,160 @@
package com.erp.ai.client;
import com.erp.ai.config.OpenClawProperties;
import com.erp.ai.exception.OpenClawException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.List;
import java.util.Map;
/**
* OpenClaw 외부 AI 엔진(port 18789) HTTP 클라이언트.
* Spring RestClient(동기) 사용 — invyone 기존 RestTemplate 패턴과 일관성 유지.
* enabled=false 이면 모든 메서드가 OpenClawException(503) 발생.
*/
@Slf4j
@Component
public class OpenClawClient {
private final OpenClawProperties props;
private final RestClient restClient;
public OpenClawClient(OpenClawProperties props) {
this.props = props;
this.restClient = RestClient.builder()
.baseUrl(props.getGatewayUrl())
.defaultHeader("X-Pipeline-Source", "invyone")
.build();
}
/**
* OpenClaw 헬스 상태 확인.
*
* @return true = 정상, false = 응답 없음 또는 비정상
*/
public boolean isHealthy() {
if (!props.isEnabled()) {
return false;
}
try {
restClient.get()
.uri("/health")
.retrieve()
.toBodilessEntity();
return true;
} catch (Exception e) {
log.warn("OpenClaw health check failed: {}", e.getMessage());
return false;
}
}
/**
* OpenAI 호환 chat/completions 프록시.
*
* @param request OpenAI 형식 요청 맵 (model, messages, ...)
* @return OpenAI 형식 응답 맵
* @throws OpenClawException 비활성 또는 HTTP 오류 시
*/
@SuppressWarnings("unchecked")
public Map<String, Object> chatCompletion(Map<String, Object> request) {
assertEnabled();
try {
return restClient.post()
.uri("/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Map.class);
} catch (RestClientResponseException e) {
log.error("OpenClaw chatCompletion HTTP error {}: {}", e.getStatusCode().value(), e.getMessage());
throw new OpenClawException("OpenClaw 응답 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
log.warn("OpenClaw chatCompletion connection failed: {}", e.getMessage());
throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw에서 사용 가능한 모델 목록 조회.
*
* @return 모델 ID 목록
* @throws OpenClawException 비활성 또는 HTTP 오류 시
*/
@SuppressWarnings("unchecked")
public List<String> listModels() {
assertEnabled();
try {
Map<String, Object> response = restClient.get()
.uri("/v1/models")
.retrieve()
.body(Map.class);
if (response == null) {
return List.of();
}
Object data = response.get("data");
if (data instanceof List<?> list) {
return list.stream()
.filter(item -> item instanceof Map)
.map(item -> (String) ((Map<?, ?>) item).get("id"))
.filter(id -> id != null)
.toList();
}
return List.of();
} catch (RestClientResponseException e) {
log.error("OpenClaw listModels HTTP error {}: {}", e.getStatusCode().value(), e.getMessage());
throw new OpenClawException("OpenClaw 모델 목록 조회 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
log.warn("OpenClaw listModels connection failed: {}", e.getMessage());
throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw auth profiles 동기화 (LLM API 키들).
*/
public void syncAuthProfiles(Map<String, Object> body) {
assertEnabled();
try {
restClient.post()
.uri("/v1/sync/auth-profiles")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (RestClientResponseException e) {
throw new OpenClawException("OpenClaw auth-profiles sync HTTP error: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
throw new OpenClawException("OpenClaw auth-profiles sync 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw agents 동기화.
*/
public void syncAgents(Map<String, Object> body) {
assertEnabled();
try {
restClient.post()
.uri("/v1/sync/agents")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (RestClientResponseException e) {
throw new OpenClawException("OpenClaw agents sync HTTP error: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
throw new OpenClawException("OpenClaw agents sync 연결 실패: " + e.getMessage(), e);
}
}
private void assertEnabled() {
if (!props.isEnabled()) {
throw new OpenClawException("OpenClaw 비활성 상태입니다. OPENCLAW_ENABLED=true 로 설정하세요.", 503);
}
}
}
@@ -0,0 +1,46 @@
package com.erp.ai.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestClient;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* AI 에이전트 인프라 빈 설정.
* - aiAgentExecutor: 가상 스레드 ExecutorService (병렬 LLM 호출용)
* - aiObjectMapper: JSON 파싱용 (Spring 기본 ObjectMapper 재사용)
* - llmRestClient: LLM HTTP 호출용 RestClient
*/
@Configuration
public class AiAgentConfig {
/**
* 가상 스레드 풀 — IO-bound LLM 호출 다수 동시 실행.
*/
@Bean(name = "aiAgentExecutor")
public ExecutorService aiAgentExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
* AI 모듈 전용 ObjectMapper. 기존 Spring 의 ObjectMapper 와 별개로 명시적으로 선언.
*/
@Bean(name = "aiObjectMapper")
public ObjectMapper aiObjectMapper() {
return new ObjectMapper();
}
/**
* LLM HTTP 클라이언트. baseUrl 미지정 — 각 LlmClient 구현체에서 절대 URL 사용.
*/
@Bean(name = "llmRestClient")
public RestClient llmRestClient() {
return RestClient.builder()
.defaultHeader("User-Agent", "invyone-ai/1.0")
.build();
}
}
@@ -0,0 +1,17 @@
package com.erp.ai.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Data
@Configuration
@ConfigurationProperties(prefix = "openclaw")
public class OpenClawProperties {
private boolean enabled = false;
private String gatewayUrl = "http://localhost:18789";
private Duration timeout = Duration.ofSeconds(60);
private Duration healthCheckInterval = Duration.ofSeconds(30);
}
@@ -0,0 +1,63 @@
package com.erp.ai.controller;
import com.erp.ai.dto.ApiKeyCreateRequest;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/keys")
@RequiredArgsConstructor
@Slf4j
public class AiAgentApiKeyController {
private final AiAgentApiKeyService apiKeyService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "role", required = false) String role) {
boolean isAdmin = "SUPER_ADMIN".equals(role) || "COMPANY_ADMIN".equals(role);
return ResponseEntity.ok(ApiResponse.success(
apiKeyService.list(userId != null ? userId : "system", isAdmin)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestBody ApiKeyCreateRequest req) {
AiAgentApiKeyService.CreatedKey result = apiKeyService.create(req,
userId != null ? userId : "system", companyCode);
Map<String, Object> data = new HashMap<>();
data.put("id", result.key().getId());
data.put("name", result.key().getName());
data.put("key_prefix", result.key().getKey_prefix());
data.put("user_id", result.key().getUser_id());
data.put("agent_id", result.key().getAgent_id());
data.put("permissions", result.key().getPermissions());
data.put("rate_limit", result.key().getRate_limit());
data.put("monthly_token_limit", result.key().getMonthly_token_limit());
data.put("status", result.key().getStatus());
data.put("expires_at", result.key().getExpires_at());
data.put("created_at", result.key().getCreated_at());
data.put("plain_key", result.plainKey());
return ResponseEntity.status(201).body(
ApiResponse.success(data, "API 키가 생성되었습니다. 키는 한 번만 표시됩니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> revoke(
@PathVariable long id,
@RequestAttribute(value = "user_id", required = false) String userId) {
apiKeyService.revoke(id, userId != null ? userId : "system");
return ResponseEntity.ok(ApiResponse.success(null, "API 키가 폐기되었습니다."));
}
}
@@ -0,0 +1,71 @@
package com.erp.ai.controller;
import com.erp.ai.dto.AgentRequest;
import com.erp.ai.model.AiAgent;
import com.erp.ai.service.AiAgentService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents")
@RequiredArgsConstructor
@Slf4j
public class AiAgentController {
private final AiAgentService aiAgentService;
@GetMapping
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String status,
@RequestParam(value = "company_code", required = false) String companyCodeParam,
@RequestParam(required = false) String search) {
boolean isSuper = "SUPER_ADMIN".equals(role);
String filter = ("*".equals(companyCodeParam) || isSuper)
? null
: (companyCodeParam != null ? companyCodeParam : companyCode);
return ResponseEntity.ok(ApiResponse.success(aiAgentService.list(status, filter, search)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> agent = aiAgentService.getById(id);
if (agent == null) {
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(agent));
}
@PostMapping
public ResponseEntity<ApiResponse<AiAgent>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody AgentRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(aiAgentService.create(req, userId != null ? userId : "system"),
"에이전트가 생성되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiAgent>> update(
@PathVariable long id,
@RequestBody AgentRequest req) {
AiAgent updated = aiAgentService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "에이전트가 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
aiAgentService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "에이전트가 삭제되었습니다."));
}
}
@@ -0,0 +1,46 @@
package com.erp.ai.controller;
import com.erp.ai.service.AiAgentConversationService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/conversations")
@RequiredArgsConstructor
public class AiAgentConversationController {
private final AiAgentConversationService conversationService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(value = "agent_id", required = false) Long agentId) {
Map<String, Object> data = conversationService.list(page, limit, agentId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", data.get("conversations"));
response.put("total", data.get("total"));
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> data = conversationService.getById(id);
if (data.get("conversation") == null) {
return ResponseEntity.status(404).body(ApiResponse.error("대화를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(data));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
conversationService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "대화가 삭제되었습니다."));
}
}
@@ -0,0 +1,92 @@
package com.erp.ai.controller;
import com.erp.ai.dto.GroupMemberRequest;
import com.erp.ai.dto.GroupRequest;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentGroupMember;
import com.erp.ai.service.AiAgentGroupService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agent-groups")
@RequiredArgsConstructor
public class AiAgentGroupController {
private final AiAgentGroupService groupService;
@GetMapping
public ResponseEntity<ApiResponse<List<AiAgentGroup>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode) {
return ResponseEntity.ok(ApiResponse.success(groupService.list(companyCode)));
}
@GetMapping("/connectors")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> connectors() {
return ResponseEntity.ok(ApiResponse.success(groupService.getAvailableConnectors()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> data = groupService.getById(id);
if (data == null) {
return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(data));
}
@PostMapping
public ResponseEntity<ApiResponse<AiAgentGroup>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody GroupRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(groupService.create(req, userId != null ? userId : "system"),
"멀티 에이전트 그룹이 생성되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiAgentGroup>> update(
@PathVariable long id,
@RequestBody GroupRequest req) {
AiAgentGroup updated = groupService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "그룹이 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
groupService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "그룹이 삭제되었습니다."));
}
// ===== 멤버 관리 =====
@PostMapping("/{id}/members")
public ResponseEntity<ApiResponse<AiAgentGroupMember>> addMember(
@PathVariable long id,
@RequestBody GroupMemberRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(groupService.addMember(id, req), "멤버가 추가되었습니다."));
}
@PutMapping("/members/{memberId}")
public ResponseEntity<ApiResponse<AiAgentGroupMember>> updateMember(
@PathVariable long memberId,
@RequestBody GroupMemberRequest req) {
return ResponseEntity.ok(
ApiResponse.success(groupService.updateMember(memberId, req), "멤버가 수정되었습니다."));
}
@DeleteMapping("/members/{memberId}")
public ResponseEntity<ApiResponse<Void>> removeMember(@PathVariable long memberId) {
groupService.removeMember(memberId);
return ResponseEntity.ok(ApiResponse.success(null, "멤버가 제거되었습니다."));
}
}
@@ -0,0 +1,50 @@
package com.erp.ai.controller;
import com.erp.ai.dto.ProviderRequest;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.service.AiAgentProviderService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/providers")
@RequiredArgsConstructor
@Slf4j
public class AiAgentProviderController {
private final AiAgentProviderService providerService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list() {
return ResponseEntity.ok(ApiResponse.success(providerService.list()));
}
@PostMapping
public ResponseEntity<ApiResponse<AiLlmProvider>> create(@RequestBody ProviderRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(providerService.create(req), "프로바이더가 추가되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiLlmProvider>> update(
@PathVariable long id,
@RequestBody ProviderRequest req) {
AiLlmProvider updated = providerService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("프로바이더를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "프로바이더가 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
providerService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "프로바이더가 삭제되었습니다."));
}
}
@@ -0,0 +1,43 @@
package com.erp.ai.controller;
import com.erp.ai.dto.UsageSummaryResponse;
import com.erp.ai.service.AiAgentUsageService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/usage")
@RequiredArgsConstructor
public class AiAgentUsageController {
private final AiAgentUsageService usageService;
@GetMapping("/summary")
public ResponseEntity<ApiResponse<UsageSummaryResponse>> summary() {
return ResponseEntity.ok(ApiResponse.success(usageService.getSummary()));
}
@GetMapping("/logs")
public ResponseEntity<Map<String, Object>> logs(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit) {
Map<String, Object> result = usageService.getLogs(page, limit);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result.get("logs"));
response.put("total", result.get("total"));
return ResponseEntity.ok(response);
}
@GetMapping("/daily")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> daily(
@RequestParam(defaultValue = "30") int days) {
return ResponseEntity.ok(ApiResponse.success(usageService.getDailyUsage(days)));
}
}
@@ -0,0 +1,71 @@
package com.erp.ai.controller;
import com.erp.ai.dto.KnowledgeFileRequest;
import com.erp.ai.model.AiKnowledgeFile;
import com.erp.ai.service.AiKnowledgeService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/ai-knowledge")
@RequiredArgsConstructor
@Slf4j
public class AiKnowledgeController {
private final AiKnowledgeService aiKnowledgeService;
@GetMapping
public ResponseEntity<ApiResponse<List<AiKnowledgeFile>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search) {
boolean isSuper = "SUPER_ADMIN".equals(role);
String filter = isSuper ? null : companyCode;
return ResponseEntity.ok(ApiResponse.success(aiKnowledgeService.list(category, filter)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<AiKnowledgeFile>> getById(@PathVariable long id) {
AiKnowledgeFile file = aiKnowledgeService.getById(id);
if (file == null) {
return ResponseEntity.status(404).body(ApiResponse.error("지식 파일을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(file));
}
@PostMapping
public ResponseEntity<ApiResponse<AiKnowledgeFile>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestBody KnowledgeFileRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(
aiKnowledgeService.create(req,
userId != null ? userId : "system",
companyCode != null ? companyCode : "*"),
"지식 파일이 등록되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiKnowledgeFile>> update(
@PathVariable long id,
@RequestBody KnowledgeFileRequest req) {
AiKnowledgeFile updated = aiKnowledgeService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("지식 파일을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "지식 파일이 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
aiKnowledgeService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "지식 파일이 삭제되었습니다."));
}
}
@@ -0,0 +1,246 @@
package com.erp.ai.controller;
import com.erp.ai.client.LlmClient;
import com.erp.ai.client.LlmClientFactory;
import com.erp.ai.dto.ChatCompletionRequest;
import com.erp.ai.dto.GroupExecutionResult;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentUsageLog;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.ai.service.AiAgentGroupService;
import com.erp.ai.service.AiAgentProviderService;
import com.erp.ai.service.AiAgentUsageService;
import com.erp.ai.service.MultiAgentExecutionEngine;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 외부 게이트웨이 (OpenAI 호환).
* vexplor openClawProxyRoutes.ts 1:1 포팅.
*
* 인증: Epic D 의 ApiKeyAuthenticationFilter 가 sk-pipe-* 검증 후
* request attribute "ai_api_key_id" / "ai_api_key_user" 설정.
* 필터 미설치 시: 헤더 fallback 처리 (개발 단계).
*/
@Slf4j
@RestController
@RequestMapping("/api/ai/v1")
@RequiredArgsConstructor
public class AiProxyController {
private final LlmClientFactory llmClientFactory;
private final MultiAgentExecutionEngine executionEngine;
private final AiAgentGroupService groupService;
private final AiAgentApiKeyService apiKeyService;
private final AiAgentUsageService usageService;
private final AiAgentProviderService providerService;
/**
* POST /api/ai/v1/chat/completions — OpenAI 호환 채팅.
*/
@PostMapping("/chat/completions")
public ResponseEntity<?> chatCompletions(@RequestBody ChatCompletionRequest req,
HttpServletRequest httpRequest) {
long startTime = System.currentTimeMillis();
ApiKeyContext ctx = resolveApiKey(httpRequest);
try {
Map<String, Object> body = new HashMap<>();
body.put("model", req.getModel());
body.put("messages", req.getMessages());
if (req.getMax_tokens() != null) body.put("max_tokens", req.getMax_tokens());
if (req.getTemperature() != null) body.put("temperature", req.getTemperature());
LlmClient client = llmClientFactory.pick(req.getModel());
Map<String, Object> result = client.chat(body);
// 사용량 추적
Map<String, Object> usage = (Map<String, Object>) result.get("usage");
int totalTokens = 0;
int promptTokens = 0;
int completionTokens = 0;
if (usage != null) {
if (usage.get("total_tokens") instanceof Number n) totalTokens = n.intValue();
if (usage.get("prompt_tokens") instanceof Number n) promptTokens = n.intValue();
if (usage.get("completion_tokens") instanceof Number n) completionTokens = n.intValue();
}
long elapsed = System.currentTimeMillis() - startTime;
AiAgentUsageLog log = new AiAgentUsageLog();
log.setUser_id(ctx.userId);
log.setApi_key_id(ctx.keyId);
log.setProvider_name(client.providerName());
log.setModel_name(req.getModel());
log.setPrompt_tokens(promptTokens);
log.setCompletion_tokens(completionTokens);
log.setTotal_tokens(totalTokens);
log.setResponse_time_ms((int) elapsed);
log.setSuccess(true);
log.setRequest_path("/v1/chat/completions");
log.setIp_address(httpRequest.getRemoteAddr());
usageService.log(log);
if (ctx.keyId != null && totalTokens > 0) {
apiKeyService.addTokenUsage(ctx.keyId, totalTokens);
}
return ResponseEntity.ok(result);
} catch (LlmClientException e) {
long elapsed = System.currentTimeMillis() - startTime;
AiAgentUsageLog log = new AiAgentUsageLog();
log.setUser_id(ctx.userId);
log.setApi_key_id(ctx.keyId);
log.setModel_name(req.getModel());
log.setResponse_time_ms((int) elapsed);
log.setSuccess(false);
log.setError_message(e.getMessage());
log.setRequest_path("/v1/chat/completions");
log.setIp_address(httpRequest.getRemoteAddr());
usageService.log(log);
return ResponseEntity.status(e.getStatusCode())
.body(Map.of("error", Map.of("message", e.getMessage(), "type", "server_error")));
}
}
/**
* POST /api/ai/v1/groups/{groupId} — 멀티 에이전트 그룹 실행.
*/
@PostMapping("/groups/{groupId}")
public ResponseEntity<?> executeGroup(@PathVariable String groupId,
@RequestBody Map<String, Object> body,
HttpServletRequest httpRequest) {
ApiKeyContext ctx = resolveApiKey(httpRequest);
Object messageObj = body.get("message");
if (messageObj == null) {
return ResponseEntity.badRequest().body(
Map.of("error", Map.of("message", "message is required", "type", "invalid_request")));
}
String message = String.valueOf(messageObj);
long actualGroupId;
try {
actualGroupId = Long.parseLong(groupId);
} catch (NumberFormatException e) {
AiAgentGroup g = groupService.getByGroupId(groupId);
if (g == null) {
return ResponseEntity.status(404).body(
Map.of("error", Map.of("message", "Group not found", "type", "not_found")));
}
actualGroupId = g.getId();
}
try {
GroupExecutionResult result = executionEngine.execute(actualGroupId, message, ctx.userId, ctx.keyId);
Map<String, Object> data = new HashMap<>();
data.put("group", result.getGroupName());
data.put("execution_mode", result.getExecutionMode());
data.put("total_tokens", result.getTotalTokens());
data.put("duration_ms", result.getTotalDurationMs());
data.put("steps", result.getSteps());
data.put("summary", result.getFinalSummary());
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", data);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(
Map.of("error", Map.of("message", e.getMessage(), "type", "execution_error")));
}
}
/**
* GET /api/ai/v1/groups — 사용 가능한 멀티 에이전트 그룹.
*/
@GetMapping("/groups")
public ResponseEntity<Map<String, Object>> listGroups() {
List<AiAgentGroup> groups = groupService.list(null);
Map<String, Object> resp = new HashMap<>();
resp.put("success", true);
resp.put("data", groups);
return ResponseEntity.ok(resp);
}
/**
* GET /api/ai/v1/models — 사용 가능한 모델 목록.
*/
@GetMapping("/models")
public ResponseEntity<Map<String, Object>> listModels() {
List<Map<String, Object>> models = new ArrayList<>();
try {
providerService.getActiveProviders().forEach(p ->
models.add(Map.of(
"id", p.getModel_name(),
"object", "model",
"owned_by", p.getName())));
} catch (Exception ignored) {
}
if (models.isEmpty()) {
// fallback
models.add(Map.of("id", "claude-sonnet-4-20250514", "object", "model", "owned_by", "anthropic"));
models.add(Map.of("id", "gpt-4o", "object", "model", "owned_by", "openai"));
models.add(Map.of("id", "gpt-4o-mini", "object", "model", "owned_by", "openai"));
}
Map<String, Object> resp = new HashMap<>();
resp.put("object", "list");
resp.put("data", models);
return ResponseEntity.ok(resp);
}
/**
* GET /api/ai/v1/health — AI 엔진 상태 확인.
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
int activeProviders;
try {
activeProviders = providerService.getActiveProviders().size();
} catch (Exception e) {
activeProviders = 0;
}
Map<String, Object> resp = new HashMap<>();
resp.put("status", activeProviders > 0 ? "running" : "no_providers");
resp.put("engine", "invyone-native");
resp.put("providers", activeProviders);
return ResponseEntity.ok(resp);
}
/**
* API key context 해석.
* 1. ApiKeyAuthenticationFilter (Epic D) 가 설정한 attribute 우선
* 2. fallback: Authorization 헤더 직접 파싱 + DB 검증
*/
private ApiKeyContext resolveApiKey(HttpServletRequest request) {
Long keyId = (Long) request.getAttribute("ai_api_key_id");
String userId = (String) request.getAttribute("ai_api_key_user");
if (keyId != null) {
return new ApiKeyContext(keyId, userId);
}
// fallback: 직접 검증
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (token.startsWith("sk-pipe-")) {
var key = apiKeyService.validateKey(token);
if (key != null) {
return new ApiKeyContext(key.getId(), key.getUser_id());
}
}
}
// JWT 컨텍스트 fallback
Object jwtUserId = request.getAttribute("user_id");
return new ApiKeyContext(null, jwtUserId != null ? String.valueOf(jwtUserId) : null);
}
private record ApiKeyContext(Long keyId, String userId) {}
}
@@ -0,0 +1,8 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class AgentExecuteRequest {
private String message;
}
@@ -0,0 +1,22 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* AI 에이전트 생성/수정 요청.
*/
@Data
public class AgentRequest {
private String agent_id;
private String name;
private String description;
private String model;
private String system_prompt;
private List<Object> tools;
private Map<String, Object> config;
private String company_code;
private String status;
}
@@ -0,0 +1,16 @@
package com.erp.ai.dto;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.List;
@Data
public class ApiKeyCreateRequest {
private String name;
private Long agent_id;
private List<String> permissions;
private Integer rate_limit;
private Long monthly_token_limit;
private OffsetDateTime expires_at;
}
@@ -0,0 +1,18 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* OpenAI 호환 chat/completions 요청 DTO.
*/
@Data
public class ChatCompletionRequest {
private String model;
private List<Map<String, Object>> messages;
private Boolean stream;
private Integer max_tokens;
private Double temperature;
}
@@ -0,0 +1,25 @@
package com.erp.ai.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.util.List;
import java.util.Map;
/**
* OpenAI 호환 chat/completions 응답 DTO.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatCompletionResponse {
private String id;
private String object;
private Long created;
private String model;
private List<Map<String, Object>> choices;
private Map<String, Object> usage;
}
@@ -0,0 +1,27 @@
package com.erp.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 멀티 에이전트 그룹 실행 결과.
* vexplor multiAgentExecutionEngine.ts:21-29 1:1 포팅.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GroupExecutionResult {
private long groupId;
private String groupName;
private String executionMode;
private List<Map<String, Object>> steps;
private String finalSummary;
private long totalTokens;
private long totalDurationMs;
}
@@ -0,0 +1,15 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class GroupMemberRequest {
private Long agent_id;
private String role_name;
private List<Map<String, Object>> connectors;
private Integer execution_order;
private Map<String, Object> config;
}
@@ -0,0 +1,13 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class GroupRequest {
private String name;
private String description;
/** parallel | sequential | mixed */
private String execution_mode;
private String company_code;
private String status;
}
@@ -0,0 +1,14 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class KnowledgeFileRequest {
private String name;
private String file_name;
private String category;
private String description;
private String content;
private Long file_size;
private String mime_type;
}
@@ -0,0 +1,21 @@
package com.erp.ai.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProviderRequest {
private String name;
private String display_name;
/** Plain text — 서비스에서 AES-GCM 암호화 후 저장. update 시 null 이면 기존 키 유지 */
private String api_key;
private String model_name;
private String endpoint;
private Integer priority;
private Integer max_tokens;
private BigDecimal temperature;
private BigDecimal cost_per_1k_input;
private BigDecimal cost_per_1k_output;
private Boolean is_active;
}
@@ -0,0 +1,16 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.Map;
@Data
public class ScheduleRequest {
private String name;
private Long group_id;
private String cron_expression;
private String timezone;
private String input_message;
private Map<String, Object> notification;
private Boolean is_active;
}
@@ -0,0 +1,23 @@
package com.erp.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Builder;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UsageSummaryResponse {
private long today_tokens;
private long today_requests;
private BigDecimal today_cost;
private long month_tokens;
private long month_requests;
private BigDecimal month_cost;
private int active_agents;
private int active_keys;
}
@@ -0,0 +1,11 @@
package com.erp.ai.exception;
public class AiAgentException extends RuntimeException {
public AiAgentException(String message) {
super(message);
}
public AiAgentException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,24 @@
package com.erp.ai.exception;
public class LlmClientException extends RuntimeException {
private final int statusCode;
public LlmClientException(String message) {
super(message);
this.statusCode = 500;
}
public LlmClientException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public LlmClientException(String message, Throwable cause) {
super(message, cause);
this.statusCode = 500;
}
public int getStatusCode() {
return statusCode;
}
}
@@ -0,0 +1,29 @@
package com.erp.ai.exception;
/**
* OpenClaw 외부 엔진 호출 실패 시 발생하는 예외.
* enabled=false 이거나 HTTP 에러 발생 시 graceful 처리용.
*/
public class OpenClawException extends RuntimeException {
private final int statusCode;
public OpenClawException(String message) {
super(message);
this.statusCode = 503;
}
public OpenClawException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public OpenClawException(String message, Throwable cause) {
super(message, cause);
this.statusCode = 503;
}
public int getStatusCode() {
return statusCode;
}
}
@@ -0,0 +1,50 @@
package com.erp.ai.health;
import com.erp.ai.client.OpenClawClient;
import com.erp.ai.config.OpenClawProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* OpenClaw 헬스 상태를 주기적으로 갱신하는 컴포넌트.
* Spring Boot Actuator 미설치 환경을 고려해 독립 구현.
* Actuator 도입 시 HealthIndicator 구현으로 전환 가능.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenClawHealthIndicator {
private final OpenClawClient openClawClient;
private final OpenClawProperties props;
private final AtomicBoolean healthy = new AtomicBoolean(false);
/**
* 설정된 health-check-interval 마다 헬스 상태 갱신.
* fixedRateString 은 application.yml openclaw.health-check-interval(ms 단위 Duration) 사용.
*/
@Scheduled(fixedRateString = "#{@openClawProperties.healthCheckInterval.toMillis()}")
public void checkHealth() {
if (!props.isEnabled()) {
healthy.set(false);
return;
}
boolean result = openClawClient.isHealthy();
if (healthy.get() != result) {
log.info("OpenClaw health changed: {}", result ? "UP" : "DOWN");
}
healthy.set(result);
}
/**
* 현재 캐시된 헬스 상태 반환.
*/
public boolean isHealthy() {
return healthy.get();
}
}
@@ -0,0 +1,28 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentApiKey;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentApiKeyMapper {
List<AiAgentApiKey> listAll();
List<AiAgentApiKey> listByUser(@Param("userId") String userId);
AiAgentApiKey getById(@Param("id") long id);
AiAgentApiKey getByKeyHash(@Param("keyHash") String keyHash);
int insert(AiAgentApiKey key);
int delete(@Param("id") long id, @Param("userId") String userId);
int updateLastUsed(@Param("id") long id);
int addTokenUsage(@Param("id") long id, @Param("tokens") long tokens);
int countActive();
}
@@ -0,0 +1,28 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentConversation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentConversationMapper {
List<AiAgentConversation> list(@Param("agentId") Long agentId,
@Param("limit") int limit,
@Param("offset") int offset);
int count(@Param("agentId") Long agentId);
AiAgentConversation getById(@Param("id") long id);
int insert(AiAgentConversation conv);
int updateMeta(@Param("id") long id,
@Param("title") String title,
@Param("metadata") String metadata);
int incrementStats(@Param("id") long id, @Param("tokens") int tokens);
int delete(@Param("id") long id);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentGroup;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentGroupMapper {
List<AiAgentGroup> list(@Param("companyCode") String companyCode);
AiAgentGroup getById(@Param("id") long id);
AiAgentGroup getByGroupId(@Param("groupId") String groupId);
int insert(AiAgentGroup group);
int update(AiAgentGroup group);
int softDelete(@Param("id") long id);
int touchUpdatedAt(@Param("id") long id);
}
@@ -0,0 +1,22 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentGroupMember;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentGroupMemberMapper {
List<AiAgentGroupMember> listByGroupId(@Param("groupId") long groupId);
AiAgentGroupMember getById(@Param("id") long id);
int insert(AiAgentGroupMember member);
int update(AiAgentGroupMember member);
int delete(@Param("id") long id);
int countByGroupId(@Param("groupId") long groupId);
}
@@ -0,0 +1,27 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiAgentMapper {
List<AiAgent> list(Map<String, Object> filters);
AiAgent getById(@Param("id") long id);
AiAgent getByAgentId(@Param("agentId") String agentId);
int insert(AiAgent agent);
int update(@Param("id") long id, @Param("fields") Map<String, Object> fields);
int softDelete(@Param("id") long id);
int countActive();
List<AiAgent> getActiveAgents();
}
@@ -0,0 +1,16 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentMessage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentMessageMapper {
List<AiAgentMessage> listByConversationId(@Param("conversationId") long conversationId);
int insert(AiAgentMessage message);
int deleteByConversationId(@Param("conversationId") long conversationId);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentScheduleMapper {
List<AiAgentSchedule> listAll();
List<AiAgentSchedule> listActive();
AiAgentSchedule getById(@Param("id") long id);
int insert(AiAgentSchedule schedule);
int update(AiAgentSchedule schedule);
int delete(@Param("id") long id);
int markRun(@Param("id") long id);
}
@@ -0,0 +1,25 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentUsageLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiAgentUsageLogMapper {
int insert(AiAgentUsageLog log);
Map<String, Object> getTodayAggregate();
Map<String, Object> getMonthAggregate();
List<AiAgentUsageLog> list(@Param("limit") int limit, @Param("offset") int offset);
int count();
List<Map<String, Object>> getDailyUsage(@Param("days") int days);
long getMonthTokensByApiKey(@Param("apiKeyId") long apiKeyId);
}
@@ -0,0 +1,19 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAnalysisLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
@Mapper
public interface AiAnalysisLogMapper {
int insert(AiAnalysisLog log);
List<AiAnalysisLog> getRecentLogs(@Param("groupId") long groupId,
@Param("days") int days,
@Param("limit") int limit);
BigDecimal getAverageAccuracy(@Param("groupId") long groupId);
}
@@ -0,0 +1,21 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiKnowledgeFile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiKnowledgeFileMapper {
List<AiKnowledgeFile> list(Map<String, Object> filters);
AiKnowledgeFile getById(@Param("id") long id);
int insert(AiKnowledgeFile file);
int update(AiKnowledgeFile file);
int delete(@Param("id") long id);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiLlmProvider;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiLlmProviderMapper {
List<AiLlmProvider> listAll();
List<AiLlmProvider> listActive();
AiLlmProvider getById(@Param("id") long id);
AiLlmProvider getByName(@Param("name") String name);
int insert(AiLlmProvider provider);
int update(AiLlmProvider provider);
int delete(@Param("id") long id);
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 모델 (테이블: ai_agents)
* vexplor types/aiAgent.ts:3-17 1:1 포팅.
*/
@Data
public class AiAgent {
private Long id;
private String agent_id;
private String name;
private String description;
private String model;
private String system_prompt;
/** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */
private String tools;
/** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */
private String config;
private String status;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,33 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI API 키 (테이블: ai_agent_api_keys)
* sk-pipe-* 형식 외부 API 인증.
*/
@Data
public class AiAgentApiKey {
private Long id;
private String name;
/** SHA-256 hex */
private String key_hash;
/** sk-pipe-{8hex} 표시용 */
private String key_prefix;
private String user_id;
private String company_code;
private Long agent_id;
/** JSONB string[] */
private String permissions;
private Integer rate_limit;
private Long monthly_token_limit;
/** active | revoked */
private String status;
private OffsetDateTime last_used_at;
private Long usage_count;
private Long total_tokens;
private OffsetDateTime expires_at;
private OffsetDateTime created_at;
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 대화 (테이블: ai_agent_conversations)
*/
@Data
public class AiAgentConversation {
private Long id;
private String conversation_id;
private Long agent_id;
private String user_id;
private Long api_key_id;
private String title;
private Integer message_count;
private Long total_tokens;
private String status;
private String metadata;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: agent_name */
private String agent_name;
/** JOIN: COALESCE(agent name, metadata->>'group_name') */
private String display_name;
}
@@ -0,0 +1,25 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 그룹 (테이블: ai_agent_groups)
*/
@Data
public class AiAgentGroup {
private Long id;
private String group_id;
private String name;
private String description;
/** parallel | sequential | mixed */
private String execution_mode;
private String status;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN 시 사용 — 멤버 수 */
private Integer member_count;
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 그룹 멤버 (테이블: ai_agent_group_members)
* connectors / config 는 JSONB → JSON 문자열로 보관.
*/
@Data
public class AiAgentGroupMember {
private Long id;
private Long group_id;
private Long agent_id;
private String role_name;
/** JSONB ConnectorRef[] */
private String connectors;
private Integer execution_order;
private String config;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: ai_agents.name */
private String agent_name;
/** JOIN: ai_agents.model */
private String agent_model;
}
@@ -0,0 +1,21 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 메시지 (테이블: ai_agent_messages)
*/
@Data
public class AiAgentMessage {
private Long id;
private Long conversation_id;
/** system | user | assistant | tool */
private String role;
private String content;
private String tool_calls;
private Integer token_count;
private String metadata;
private OffsetDateTime created_at;
}
@@ -0,0 +1,30 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 스케줄 (테이블: ai_agent_schedules)
* Quartz JobStore + Cron.
*/
@Data
public class AiAgentSchedule {
private Long id;
private String name;
private Long group_id;
private String cron_expression;
private String timezone;
private String input_message;
private String notification;
private Boolean is_active;
private OffsetDateTime last_run_at;
private Long run_count;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: ai_agent_groups.name */
private String group_name;
}
@@ -0,0 +1,31 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* AI 사용량 로그 (테이블: ai_agent_usage_logs)
*/
@Data
public class AiAgentUsageLog {
private Long id;
private String user_id;
private Long api_key_id;
private Long agent_id;
private Long conversation_id;
private String provider_name;
private String model_name;
private Integer prompt_tokens;
private Integer completion_tokens;
private Integer total_tokens;
private BigDecimal cost_usd;
private Integer response_time_ms;
private Boolean success;
private String error_message;
private String request_path;
private String ip_address;
private String company_code;
private OffsetDateTime created_at;
}
@@ -0,0 +1,29 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* AI 분석 이력 (테이블: ai_analysis_logs)
*/
@Data
public class AiAnalysisLog {
private Long id;
private Long group_id;
private Long agent_id;
private Long schedule_id;
/** manual | api | schedule */
private String execution_type;
private String input_message;
private String analysis_result;
private String prediction;
private String actual_result;
private BigDecimal accuracy_score;
private Integer tokens_used;
private Integer duration_ms;
private String metadata;
private String company_code;
private OffsetDateTime created_at;
}
@@ -0,0 +1,24 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 지식 파일 (테이블: ai_knowledge_files)
*/
@Data
public class AiKnowledgeFile {
private Long id;
private String name;
private String file_name;
private String category;
private String description;
private String content;
private Long file_size;
private String mime_type;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,30 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* LLM 프로바이더 (테이블: ai_llm_providers)
* api_key_encrypted 는 AES-GCM 암호화 base64 문자열.
*/
@Data
public class AiLlmProvider {
private Long id;
/** anthropic | openai | google | deepseek | ollama */
private String name;
private String display_name;
private String api_key_encrypted;
private String model_name;
private String endpoint;
private Integer priority;
private Integer max_tokens;
private BigDecimal temperature;
private BigDecimal cost_per_1k_input;
private BigDecimal cost_per_1k_output;
private Boolean is_active;
private String config;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,87 @@
package com.erp.ai.scheduler;
import com.erp.ai.dto.GroupExecutionResult;
import com.erp.ai.mapper.AiAgentScheduleMapper;
import com.erp.ai.model.AiAgentSchedule;
import com.erp.ai.service.MultiAgentExecutionEngine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* Quartz Job — AI 스케줄 실행.
* architecture §8.2.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MultiAgentExecutionJob implements Job {
@Autowired
private MultiAgentExecutionEngine executionEngine;
@Autowired
private AiAgentScheduleMapper scheduleMapper;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
long scheduleId = context.getMergedJobDataMap().getLong("scheduleId");
AiAgentSchedule schedule = scheduleMapper.getById(scheduleId);
if (schedule == null) {
log.warn("스케줄 없음: {}", scheduleId);
return;
}
log.info("AI 스케줄 실행: {} (ID: {})", schedule.getName(), schedule.getId());
try {
GroupExecutionResult result = executionEngine.execute(
schedule.getGroup_id(),
schedule.getInput_message(),
schedule.getCreated_by(),
null);
scheduleMapper.markRun(schedule.getId());
sendNotification(schedule, result);
log.info("AI 스케줄 완료: {} - {} tokens", schedule.getName(), result.getTotalTokens());
} catch (Exception e) {
log.error("AI 스케줄 실패: {} - {}", schedule.getName(), e.getMessage());
}
}
@SuppressWarnings("unchecked")
private void sendNotification(AiAgentSchedule schedule, GroupExecutionResult result) {
if (schedule.getNotification() == null) return;
try {
// notification 은 JSON 문자열 — 단순 처리: webhook 만 RestClient.post
// 시스템 공지/이메일은 invyone 서비스 호출 (Phase 2).
String n = schedule.getNotification();
if (n.contains("\"webhook\"")) {
int idx = n.indexOf("\"webhook\"");
int colon = n.indexOf(':', idx);
int quoteOpen = n.indexOf('"', colon + 1);
int quoteClose = n.indexOf('"', quoteOpen + 1);
if (quoteOpen > 0 && quoteClose > quoteOpen) {
String webhook = n.substring(quoteOpen + 1, quoteClose);
if (webhook.startsWith("http")) {
RestClient.create().post().uri(webhook)
.body(Map.of("text",
"[" + schedule.getName() + "] 실행 완료\n\n"
+ (result.getFinalSummary().length() > 1000
? result.getFinalSummary().substring(0, 1000)
: result.getFinalSummary())))
.retrieve()
.toBodilessEntity();
}
}
}
} catch (Exception e) {
log.warn("알림 발송 실패: {}", e.getMessage());
}
}
}
@@ -0,0 +1,146 @@
package com.erp.ai.security;
import com.erp.ai.model.AiAgentApiKey;
import com.erp.ai.service.AiAgentApiKeyService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* /api/ai/v1/** 엔드포인트에 대한 API 키 인증 필터.
* Authorization: Bearer sk-pipe-{hex} 형식의 키를 검증합니다.
* SubdomainResolverFilter 뒤, JwtAuthenticationFilter 앞에 위치합니다.
*/
@Slf4j
@RequiredArgsConstructor
public class AiApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_PREFIX = "sk-pipe-";
private static final String AI_API_PATH_PATTERN = "/api/ai/v1/**";
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AiAgentApiKeyService aiAgentApiKeyService;
/**
* /api/ai/v1/** 외 경로는 이 필터를 건너뜁니다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !PATH_MATCHER.match(AI_API_PATH_PATTERN, path);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
// Authorization 헤더가 없거나 sk-pipe- 로 시작하지 않으면 JWT 필터로 패스
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer " + API_KEY_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// "Bearer " 제거 후 실제 키 값 추출
String rawKey = authHeader.substring(7); // "Bearer ".length() == 7
// 키 검증 + last_used_at 갱신 (Epic C 서비스가 SHA-256 + 만료체크 + last_used 갱신을 수행)
AiAgentApiKey apiKey;
try {
apiKey = aiAgentApiKeyService.validateKey(rawKey);
} catch (Exception e) {
log.error("[AiApiKeyAuthFilter] Key validation failure", e);
sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR", "Internal server error");
return;
}
if (apiKey == null) {
log.warn("[AiApiKeyAuthFilter] Invalid or expired API key. prefix={}",
rawKey.length() > 16 ? rawKey.substring(0, 16) + "..." : rawKey);
sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
"INVALID_API_KEY", "Invalid or expired API key");
return;
}
// 만료일 이중 검증 (서비스에서 처리하지 않을 경우 대비)
if (apiKey.getExpires_at() != null && apiKey.getExpires_at().isBefore(OffsetDateTime.now())) {
log.warn("[AiApiKeyAuthFilter] API key expired. keyId={}", apiKey.getId());
sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
"API_KEY_EXPIRED", "API key has expired");
return;
}
// 월간 토큰 한도 체크
if (apiKey.getMonthly_token_limit() != null
&& apiKey.getMonthly_token_limit() > 0
&& apiKey.getTotal_tokens() != null
&& apiKey.getTotal_tokens() >= apiKey.getMonthly_token_limit()) {
log.warn("[AiApiKeyAuthFilter] Monthly token limit exceeded. keyId={}", apiKey.getId());
sendError(response, 429,
"TOKEN_LIMIT_EXCEEDED", "Monthly token limit exceeded");
return;
}
// Request attribute 설정 (JwtAuthenticationFilter 와 동일한 키 사용)
request.setAttribute("user_id", apiKey.getUser_id());
request.setAttribute("company_code", apiKey.getCompany_code());
request.setAttribute("role", "API_KEY_USER");
request.setAttribute("user_type", "API_KEY_USER");
request.setAttribute("api_key_id", apiKey.getId());
// AiProxyController 가 사용하는 attribute 이름과 일치시킴
request.setAttribute("ai_api_key_id", apiKey.getId());
request.setAttribute("ai_api_key_user", apiKey.getUser_id());
// SecurityContext 설정
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_API_KEY_USER")
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(apiKey.getUser_id(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("[AiApiKeyAuthFilter] Authenticated via API key. keyId={} userId={} companyCode={}",
apiKey.getId(), apiKey.getUser_id(), apiKey.getCompany_code());
filterChain.doFilter(request, response);
}
/**
* 401/429 JSON 오류 응답 전송.
*/
private void sendError(HttpServletResponse response, int status, String code, String message)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Map<String, Object> body = Map.of(
"error", Map.of(
"code", code,
"message", message
)
);
response.getWriter().write(OBJECT_MAPPER.writeValueAsString(body));
}
}
@@ -0,0 +1,167 @@
package com.erp.ai.service;
import com.erp.ai.dto.ApiKeyCreateRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentApiKeyMapper;
import com.erp.ai.model.AiAgentApiKey;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
/**
* AI API 키 관리 서비스.
* vexplor aiAgentApiKeyService.ts 1:1 포팅.
* - sk-pipe-{64hex} 발급
* - SHA-256 hash 저장 (plain key는 1회 반환 후 폐기)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentApiKeyService {
private final AiAgentApiKeyMapper apiKeyMapper;
private final SecureRandom secureRandom = new SecureRandom();
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<Map<String, Object>> list(String userId, boolean isAdmin) {
List<AiAgentApiKey> keys = isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
return keys.stream().map(this::toResponseMap).toList();
}
private Map<String, Object> toResponseMap(AiAgentApiKey k) {
Map<String, Object> row = new HashMap<>();
row.put("id", k.getId());
row.put("name", k.getName());
row.put("key_prefix", k.getKey_prefix());
row.put("user_id", k.getUser_id());
row.put("company_code", k.getCompany_code());
row.put("agent_id", k.getAgent_id());
row.put("permissions", k.getPermissions());
row.put("rate_limit", k.getRate_limit());
row.put("monthly_token_limit", k.getMonthly_token_limit());
row.put("status", k.getStatus());
row.put("last_used_at", k.getLast_used_at());
row.put("usage_count", k.getUsage_count());
row.put("total_tokens", k.getTotal_tokens());
row.put("expires_at", k.getExpires_at());
row.put("created_at", k.getCreated_at());
parseJsonField(row, "permissions");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
public AiAgentApiKey getById(long id) {
return apiKeyMapper.getById(id);
}
/** {key, plainKey} 쌍 반환 */
public CreatedKey create(ApiKeyCreateRequest req, String userId, String companyCode) {
GeneratedKey gen = generateKey();
AiAgentApiKey key = new AiAgentApiKey();
key.setName(req.getName());
key.setKey_hash(gen.hash);
key.setKey_prefix(gen.prefix);
key.setUser_id(userId);
key.setCompany_code(companyCode);
key.setAgent_id(req.getAgent_id());
key.setPermissions(toJsonString(req.getPermissions() != null ? req.getPermissions() : List.of("chat")));
key.setRate_limit(req.getRate_limit() != null ? req.getRate_limit() : 60);
key.setMonthly_token_limit(req.getMonthly_token_limit() != null ? req.getMonthly_token_limit() : 1_000_000L);
key.setExpires_at(req.getExpires_at());
apiKeyMapper.insert(key);
AiAgentApiKey saved = apiKeyMapper.getById(key.getId());
log.info("API 키 생성: {}... (by {})", gen.prefix, userId);
return new CreatedKey(saved, gen.plainKey);
}
@Transactional
public void revoke(long id, String userId) {
apiKeyMapper.delete(id, userId);
log.info("API 키 삭제: id={} (by {})", id, userId);
}
/**
* 외부 API 키 검증 — SHA-256 매칭 + 만료 체크 + last_used 갱신.
* @return null = 무효, 객체 = 유효
*/
@Transactional
public AiAgentApiKey validateKey(String plainKey) {
if (plainKey == null || !plainKey.startsWith("sk-pipe-")) return null;
String hash = sha256Hex(plainKey);
AiAgentApiKey key = apiKeyMapper.getByKeyHash(hash);
if (key == null) return null;
if (key.getExpires_at() != null && key.getExpires_at().isBefore(OffsetDateTime.now())) {
return null;
}
apiKeyMapper.updateLastUsed(key.getId());
return key;
}
public void addTokenUsage(long keyId, long tokens) {
apiKeyMapper.addTokenUsage(keyId, tokens);
}
public int countActive() {
return apiKeyMapper.countActive();
}
private GeneratedKey generateKey() {
byte[] randomBytes = new byte[32];
secureRandom.nextBytes(randomBytes);
String hex = HexFormat.of().formatHex(randomBytes);
String plainKey = "sk-pipe-" + hex;
String hash = sha256Hex(plainKey);
String prefix = plainKey.substring(0, 16);
return new GeneratedKey(plainKey, hash, prefix);
}
private String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new AiAgentException("SHA-256 미지원", e);
}
}
private String toJsonString(Object value) {
if (value == null) return null;
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new AiAgentException("JSON 직렬화 실패", e);
}
}
public record CreatedKey(AiAgentApiKey key, String plainKey) {}
private record GeneratedKey(String plainKey, String hash, String prefix) {}
}
@@ -0,0 +1,150 @@
package com.erp.ai.service;
import com.erp.ai.mapper.AiAgentConversationMapper;
import com.erp.ai.mapper.AiAgentMessageMapper;
import com.erp.ai.model.AiAgentConversation;
import com.erp.ai.model.AiAgentMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
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.UUID;
/**
* AI 대화 모니터링 서비스.
* vexplor aiAgentConversationService.ts 1:1 포팅.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentConversationService {
private final AiAgentConversationMapper conversationMapper;
private final AiAgentMessageMapper messageMapper;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public Map<String, Object> list(int page, int limit, Long agentId) {
int offset = (page - 1) * limit;
List<AiAgentConversation> conversations = conversationMapper.list(agentId, limit, offset);
int total = conversationMapper.count(agentId);
Map<String, Object> result = new HashMap<>();
result.put("conversations", conversations.stream().map(this::convToResponseMap).toList());
result.put("total", total);
return result;
}
public Map<String, Object> getById(long id) {
AiAgentConversation conv = conversationMapper.getById(id);
List<AiAgentMessage> messages = conv != null
? messageMapper.listByConversationId(id)
: List.of();
Map<String, Object> result = new HashMap<>();
result.put("conversation", conv != null ? convToResponseMap(conv) : null);
result.put("messages", messages.stream().map(this::messageToResponseMap).toList());
return result;
}
private Map<String, Object> convToResponseMap(AiAgentConversation c) {
Map<String, Object> row = new HashMap<>();
row.put("id", c.getId());
row.put("conversation_id", c.getConversation_id());
row.put("agent_id", c.getAgent_id());
row.put("user_id", c.getUser_id());
row.put("api_key_id", c.getApi_key_id());
row.put("title", c.getTitle());
row.put("message_count", c.getMessage_count());
row.put("total_tokens", c.getTotal_tokens());
row.put("status", c.getStatus());
row.put("metadata", c.getMetadata());
row.put("created_at", c.getCreated_at());
row.put("updated_at", c.getUpdated_at());
row.put("agent_name", c.getAgent_name());
row.put("display_name", c.getDisplay_name());
parseJsonField(row, "metadata");
return row;
}
private Map<String, Object> messageToResponseMap(AiAgentMessage m) {
Map<String, Object> row = new HashMap<>();
row.put("id", m.getId());
row.put("conversation_id", m.getConversation_id());
row.put("role", m.getRole());
row.put("content", m.getContent());
row.put("tool_calls", m.getTool_calls());
row.put("token_count", m.getToken_count());
row.put("metadata", m.getMetadata());
row.put("created_at", m.getCreated_at());
parseJsonField(row, "tool_calls");
parseJsonField(row, "metadata");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgentConversation createConversation(Long agentId, String userId, Long apiKeyId) {
AiAgentConversation conv = new AiAgentConversation();
conv.setConversation_id("conv-" + UUID.randomUUID());
conv.setAgent_id(agentId);
conv.setUser_id(userId);
conv.setApi_key_id(apiKeyId);
conv.setMetadata("{}");
conversationMapper.insert(conv);
return conversationMapper.getById(conv.getId());
}
@Transactional
public void updateMeta(long id, String title, String metadataJson) {
conversationMapper.updateMeta(id, title, metadataJson);
}
@Transactional
public AiAgentMessage addMessage(long conversationId, String role, String content,
int tokenCount, String toolCallsJson) {
AiAgentMessage msg = new AiAgentMessage();
msg.setConversation_id(conversationId);
msg.setRole(role);
msg.setContent(content);
msg.setToken_count(tokenCount);
msg.setTool_calls(toolCallsJson);
messageMapper.insert(msg);
conversationMapper.incrementStats(conversationId, tokenCount);
return msg;
}
@Transactional
public AiAgentMessage addMessageWithMetadata(long conversationId, String role, String content,
int tokenCount, String metadataJson) {
AiAgentMessage msg = new AiAgentMessage();
msg.setConversation_id(conversationId);
msg.setRole(role);
msg.setContent(content);
msg.setToken_count(tokenCount);
msg.setMetadata(metadataJson);
messageMapper.insert(msg);
conversationMapper.incrementStats(conversationId, tokenCount);
return msg;
}
@Transactional
public void delete(long id) {
conversationMapper.delete(id);
}
}
@@ -0,0 +1,193 @@
package com.erp.ai.service;
import com.erp.ai.dto.GroupMemberRequest;
import com.erp.ai.dto.GroupRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentGroupMapper;
import com.erp.ai.mapper.AiAgentGroupMemberMapper;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentGroupMember;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI 에이전트 그룹 서비스.
* vexplor aiAgentGroupService.ts 1:1 포팅.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentGroupService {
private final AiAgentGroupMapper groupMapper;
private final AiAgentGroupMemberMapper memberMapper;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<AiAgentGroup> list(String companyCode) {
return groupMapper.list(companyCode);
}
public Map<String, Object> getById(long id) {
AiAgentGroup group = groupMapper.getById(id);
if (group == null) return null;
List<AiAgentGroupMember> members = memberMapper.listByGroupId(id);
Map<String, Object> result = new HashMap<>();
result.put("id", group.getId());
result.put("group_id", group.getGroup_id());
result.put("name", group.getName());
result.put("description", group.getDescription());
result.put("execution_mode", group.getExecution_mode());
result.put("status", group.getStatus());
result.put("company_code", group.getCompany_code());
result.put("created_by", group.getCreated_by());
result.put("created_at", group.getCreated_at());
result.put("updated_at", group.getUpdated_at());
result.put("members", members.stream().map(this::memberToResponseMap).toList());
return result;
}
/**
* AiAgentGroupMember → API 응답용 Map.
* JSONB(::text) 컬럼 connectors / config 를 Object 로 파싱.
* MultiAgentExecutionEngine 의 raw String 사용처는 model 을 직접 사용하므로 영향 없음.
*/
private Map<String, Object> memberToResponseMap(AiAgentGroupMember m) {
Map<String, Object> row = new HashMap<>();
row.put("id", m.getId());
row.put("group_id", m.getGroup_id());
row.put("agent_id", m.getAgent_id());
row.put("role_name", m.getRole_name());
row.put("connectors", m.getConnectors());
row.put("execution_order", m.getExecution_order());
row.put("config", m.getConfig());
row.put("created_at", m.getCreated_at());
row.put("updated_at", m.getUpdated_at());
row.put("agent_name", m.getAgent_name());
row.put("agent_model", m.getAgent_model());
parseJsonField(row, "connectors");
parseJsonField(row, "config");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
public AiAgentGroup getEntityById(long id) {
return groupMapper.getById(id);
}
public AiAgentGroup getByGroupId(String groupId) {
return groupMapper.getByGroupId(groupId);
}
public List<AiAgentGroupMember> listMembers(long groupId) {
return memberMapper.listByGroupId(groupId);
}
@Transactional
public AiAgentGroup create(GroupRequest req, String userId) {
AiAgentGroup group = new AiAgentGroup();
group.setGroup_id("group-" + Long.toString(System.currentTimeMillis(), 36));
group.setName(req.getName());
group.setDescription(req.getDescription());
group.setExecution_mode(req.getExecution_mode() != null ? req.getExecution_mode() : "mixed");
group.setStatus("active");
group.setCompany_code(req.getCompany_code());
group.setCreated_by(userId);
groupMapper.insert(group);
log.info("멀티 에이전트 그룹 생성: {} (by {})", req.getName(), userId);
return groupMapper.getById(group.getId());
}
@Transactional
public AiAgentGroup update(long id, GroupRequest req) {
AiAgentGroup existing = groupMapper.getById(id);
if (existing == null) return null;
AiAgentGroup g = new AiAgentGroup();
g.setId(id);
g.setName(req.getName());
g.setDescription(req.getDescription());
g.setExecution_mode(req.getExecution_mode());
g.setStatus(req.getStatus());
groupMapper.update(g);
return groupMapper.getById(id);
}
@Transactional
public void delete(long id) {
groupMapper.softDelete(id);
}
// ===== 멤버 관리 =====
@Transactional
public AiAgentGroupMember addMember(long groupId, GroupMemberRequest req) {
if (req.getAgent_id() == null) throw new AiAgentException("agent_id 가 필요합니다.");
AiAgentGroupMember member = new AiAgentGroupMember();
member.setGroup_id(groupId);
member.setAgent_id(req.getAgent_id());
member.setRole_name(req.getRole_name());
member.setConnectors(toJsonString(req.getConnectors() != null ? req.getConnectors() : List.of()));
member.setExecution_order(req.getExecution_order() != null ? req.getExecution_order() : 1);
member.setConfig(toJsonString(req.getConfig() != null ? req.getConfig() : Map.of()));
memberMapper.insert(member);
groupMapper.touchUpdatedAt(groupId);
return memberMapper.getById(member.getId());
}
@Transactional
public AiAgentGroupMember updateMember(long memberId, GroupMemberRequest req) {
AiAgentGroupMember m = new AiAgentGroupMember();
m.setId(memberId);
m.setRole_name(req.getRole_name());
if (req.getConnectors() != null) m.setConnectors(toJsonString(req.getConnectors()));
m.setExecution_order(req.getExecution_order());
if (req.getConfig() != null) m.setConfig(toJsonString(req.getConfig()));
memberMapper.update(m);
return memberMapper.getById(memberId);
}
@Transactional
public void removeMember(long memberId) {
memberMapper.delete(memberId);
}
/**
* 사용 가능한 커넥터 목록.
* vexplor 의 try/catch + empty fallback 동일.
* invyone에는 external_db_connections 등 테이블이 없어 빈 배열 반환.
*/
public List<Map<String, Object>> getAvailableConnectors() {
// architecture §3.13: invyone에 부재 — 빈 배열 fallback.
return List.of();
}
private String toJsonString(Object value) {
if (value == null) return null;
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new AiAgentException("JSON 직렬화 실패", e);
}
}
}
@@ -0,0 +1,150 @@
package com.erp.ai.service;
import com.erp.ai.dto.ProviderRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* LLM 프로바이더 관리 서비스.
* vexplor aiAgentProviderService.ts 1:1 포팅.
* - LLM API 키는 AES-GCM 암호화 저장
* - 목록 조회 시 키 마스킹 (****+last4)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentProviderService {
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
/** 키 마스킹된 목록 */
public List<Map<String, Object>> list() {
return providerMapper.listAll().stream()
.map(this::toMaskedMap)
.toList();
}
public Map<String, Object> getById(long id) {
AiLlmProvider p = providerMapper.getById(id);
if (p == null) return null;
return toMaskedMap(p);
}
/** 내부용 — model 원본 반환 (getActiveProviders, LLM 호출 경로) */
public AiLlmProvider getEntityById(long id) {
return providerMapper.getById(id);
}
public List<AiLlmProvider> getActiveProviders() {
return providerMapper.listActive();
}
@Transactional
public AiLlmProvider create(ProviderRequest req) {
if (req.getApi_key() == null || req.getApi_key().isBlank()) {
throw new AiAgentException("api_key 가 필요합니다.");
}
AiLlmProvider provider = new AiLlmProvider();
provider.setName(req.getName());
provider.setDisplay_name(req.getDisplay_name());
provider.setApi_key_encrypted(cipher.encrypt(req.getApi_key()));
provider.setModel_name(req.getModel_name());
provider.setEndpoint(req.getEndpoint());
provider.setPriority(req.getPriority());
provider.setMax_tokens(req.getMax_tokens());
provider.setTemperature(req.getTemperature());
provider.setCost_per_1k_input(req.getCost_per_1k_input());
provider.setCost_per_1k_output(req.getCost_per_1k_output());
provider.setIs_active(req.getIs_active() != null ? req.getIs_active() : Boolean.TRUE);
providerMapper.insert(provider);
log.info("LLM 프로바이더 추가: {} ({})", req.getName(), req.getModel_name());
return providerMapper.getById(provider.getId());
}
@Transactional
public AiLlmProvider update(long id, ProviderRequest req) {
AiLlmProvider existing = providerMapper.getById(id);
if (existing == null) return null;
AiLlmProvider provider = new AiLlmProvider();
provider.setId(id);
provider.setDisplay_name(req.getDisplay_name());
if (req.getApi_key() != null && !req.getApi_key().isBlank()) {
provider.setApi_key_encrypted(cipher.encrypt(req.getApi_key()));
}
provider.setModel_name(req.getModel_name());
provider.setEndpoint(req.getEndpoint());
provider.setPriority(req.getPriority());
provider.setMax_tokens(req.getMax_tokens());
provider.setTemperature(req.getTemperature());
provider.setCost_per_1k_input(req.getCost_per_1k_input());
provider.setCost_per_1k_output(req.getCost_per_1k_output());
provider.setIs_active(req.getIs_active());
providerMapper.update(provider);
return providerMapper.getById(id);
}
@Transactional
public void delete(long id) {
providerMapper.delete(id);
}
/** 복호화된 키 반환 (서비스 내부에서만 사용) */
public String getDecryptedKey(long id) {
AiLlmProvider p = providerMapper.getById(id);
if (p == null) return null;
return cipher.decrypt(p.getApi_key_encrypted());
}
private Map<String, Object> toMaskedMap(AiLlmProvider p) {
Map<String, Object> m = new HashMap<>();
m.put("id", p.getId());
m.put("name", p.getName());
m.put("display_name", p.getDisplay_name());
m.put("model_name", p.getModel_name());
m.put("endpoint", p.getEndpoint());
m.put("priority", p.getPriority());
m.put("max_tokens", p.getMax_tokens());
m.put("temperature", p.getTemperature());
m.put("cost_per_1k_input", p.getCost_per_1k_input());
m.put("cost_per_1k_output", p.getCost_per_1k_output());
m.put("is_active", p.getIs_active());
m.put("config", p.getConfig());
m.put("created_at", p.getCreated_at());
m.put("updated_at", p.getUpdated_at());
// 마스킹: 마지막 4글자만 노출
String enc = p.getApi_key_encrypted();
m.put("api_key_masked", enc != null && enc.length() >= 4 ? "****" + enc.substring(enc.length() - 4) : "");
parseJsonField(m, "config");
return m;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
}
@@ -0,0 +1,150 @@
package com.erp.ai.service;
import com.erp.ai.dto.AgentRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentMapper;
import com.erp.ai.model.AiAgent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI 에이전트 CRUD 서비스.
* vexplor aiAgentService.ts 1:1 포팅.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentService {
private final AiAgentMapper agentMapper;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<Map<String, Object>> list(String status, String companyCode, String search) {
Map<String, Object> filters = new HashMap<>();
if (status != null) filters.put("status", status);
if (companyCode != null) filters.put("company_code", companyCode);
if (search != null && !search.isBlank()) filters.put("search", "%" + search + "%");
return agentMapper.list(filters).stream().map(this::toResponseMap).toList();
}
public Map<String, Object> getById(long id) {
AiAgent agent = agentMapper.getById(id);
if (agent == null) return null;
return toResponseMap(agent);
}
/** 내부용 — model 원본 반환 (MultiAgentExecutionEngine 등 LLM 호출 경로는 agentMapper 직접 사용) */
public AiAgent getEntityById(long id) {
return agentMapper.getById(id);
}
public AiAgent getByAgentId(String agentId) {
return agentMapper.getByAgentId(agentId);
}
private Map<String, Object> toResponseMap(AiAgent a) {
Map<String, Object> row = new HashMap<>();
row.put("id", a.getId());
row.put("agent_id", a.getAgent_id());
row.put("name", a.getName());
row.put("description", a.getDescription());
row.put("model", a.getModel());
row.put("system_prompt", a.getSystem_prompt());
row.put("tools", a.getTools());
row.put("config", a.getConfig());
row.put("status", a.getStatus());
row.put("company_code", a.getCompany_code());
row.put("created_by", a.getCreated_by());
row.put("created_at", a.getCreated_at());
row.put("updated_at", a.getUpdated_at());
parseJsonField(row, "tools");
parseJsonField(row, "config");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgent create(AgentRequest req, String userId) {
if (req.getAgent_id() == null || req.getAgent_id().isBlank()) {
throw new AiAgentException("agent_id 가 필요합니다.");
}
if (agentMapper.getByAgentId(req.getAgent_id()) != null) {
throw new AiAgentException("이미 존재하는 에이전트 ID입니다.");
}
AiAgent agent = new AiAgent();
agent.setAgent_id(req.getAgent_id());
agent.setName(req.getName());
agent.setDescription(req.getDescription());
agent.setModel(req.getModel());
agent.setSystem_prompt(req.getSystem_prompt());
agent.setTools(toJsonString(req.getTools()));
agent.setConfig(toJsonString(req.getConfig()));
agent.setStatus(req.getStatus() != null ? req.getStatus() : "active");
agent.setCompany_code(req.getCompany_code());
agent.setCreated_by(userId);
agentMapper.insert(agent);
log.info("에이전트 생성: {} (by {})", req.getAgent_id(), userId);
return agentMapper.getById(agent.getId());
}
@Transactional
public AiAgent update(long id, AgentRequest req) {
AiAgent existing = agentMapper.getById(id);
if (existing == null) return null;
Map<String, Object> fields = new HashMap<>();
if (req.getName() != null) fields.put("name", req.getName());
if (req.getDescription() != null) fields.put("description", req.getDescription());
if (req.getModel() != null) fields.put("model", req.getModel());
if (req.getSystem_prompt() != null) fields.put("system_prompt", req.getSystem_prompt());
if (req.getTools() != null) fields.put("tools", toJsonString(req.getTools()));
if (req.getConfig() != null) fields.put("config", toJsonString(req.getConfig()));
if (req.getStatus() != null) fields.put("status", req.getStatus());
if (!fields.isEmpty()) {
agentMapper.update(id, fields);
}
return agentMapper.getById(id);
}
@Transactional
public void delete(long id) {
agentMapper.softDelete(id);
}
public List<AiAgent> getActiveAgents() {
return agentMapper.getActiveAgents();
}
private String toJsonString(Object value) {
if (value == null) return null;
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new AiAgentException("JSON 직렬화 실패", e);
}
}
}
@@ -0,0 +1,83 @@
package com.erp.ai.service;
import com.erp.ai.dto.UsageSummaryResponse;
import com.erp.ai.mapper.AiAgentApiKeyMapper;
import com.erp.ai.mapper.AiAgentMapper;
import com.erp.ai.mapper.AiAgentUsageLogMapper;
import com.erp.ai.model.AiAgentUsageLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI 사용량/비용 집계 서비스.
* vexplor aiAgentUsageService.ts 1:1 포팅.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentUsageService {
private final AiAgentUsageLogMapper usageMapper;
private final AiAgentMapper agentMapper;
private final AiAgentApiKeyMapper apiKeyMapper;
public void log(AiAgentUsageLog data) {
if (data.getSuccess() == null) data.setSuccess(true);
usageMapper.insert(data);
}
public UsageSummaryResponse getSummary() {
Map<String, Object> today = usageMapper.getTodayAggregate();
Map<String, Object> month = usageMapper.getMonthAggregate();
int activeAgents = agentMapper.countActive();
int activeKeys = apiKeyMapper.countActive();
return UsageSummaryResponse.builder()
.today_tokens(toLong(today.get("tokens")))
.today_requests(toLong(today.get("requests")))
.today_cost(toBigDecimal(today.get("cost")))
.month_tokens(toLong(month.get("tokens")))
.month_requests(toLong(month.get("requests")))
.month_cost(toBigDecimal(month.get("cost")))
.active_agents(activeAgents)
.active_keys(activeKeys)
.build();
}
public Map<String, Object> getLogs(int page, int limit) {
int offset = (page - 1) * limit;
List<AiAgentUsageLog> logs = usageMapper.list(limit, offset);
int total = usageMapper.count();
Map<String, Object> result = new HashMap<>();
result.put("logs", logs);
result.put("total", total);
return result;
}
public List<Map<String, Object>> getDailyUsage(int days) {
return usageMapper.getDailyUsage(days);
}
public long getMonthTokensByApiKey(long apiKeyId) {
return usageMapper.getMonthTokensByApiKey(apiKeyId);
}
private long toLong(Object v) {
if (v == null) return 0L;
if (v instanceof Number n) return n.longValue();
return Long.parseLong(v.toString());
}
private BigDecimal toBigDecimal(Object v) {
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal b) return b;
if (v instanceof Number n) return BigDecimal.valueOf(n.doubleValue());
return new BigDecimal(v.toString());
}
}
@@ -0,0 +1,36 @@
package com.erp.ai.service;
import com.erp.ai.mapper.AiAnalysisLogMapper;
import com.erp.ai.model.AiAnalysisLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/**
* AI 분석 이력 서비스.
* vexplor aiAnalysisLogService.ts 1:1 포팅 (역공학된 인터페이스).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAnalysisLogService {
private final AiAnalysisLogMapper analysisLogMapper;
public void save(AiAnalysisLog log) {
if (log.getExecution_type() == null) log.setExecution_type("manual");
analysisLogMapper.insert(log);
}
public List<AiAnalysisLog> getRecentLogs(long groupId, int days, int limit) {
return analysisLogMapper.getRecentLogs(groupId, days, limit);
}
public BigDecimal getAverageAccuracy(long groupId) {
BigDecimal v = analysisLogMapper.getAverageAccuracy(groupId);
return v != null ? v : BigDecimal.ZERO;
}
}
@@ -0,0 +1,72 @@
package com.erp.ai.service;
import com.erp.ai.dto.KnowledgeFileRequest;
import com.erp.ai.mapper.AiKnowledgeFileMapper;
import com.erp.ai.model.AiKnowledgeFile;
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;
/**
* AI 지식 파일 서비스.
* RAG 라이브러리 + 커스텀 업로드.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiKnowledgeService {
private final AiKnowledgeFileMapper knowledgeMapper;
public List<AiKnowledgeFile> list(String category, String companyCode) {
Map<String, Object> filters = new HashMap<>();
if (category != null) filters.put("category", category);
if (companyCode != null) filters.put("company_code", companyCode);
return knowledgeMapper.list(filters);
}
public AiKnowledgeFile getById(long id) {
return knowledgeMapper.getById(id);
}
@Transactional
public AiKnowledgeFile create(KnowledgeFileRequest req, String userId, String companyCode) {
AiKnowledgeFile f = new AiKnowledgeFile();
f.setName(req.getName());
f.setFile_name(req.getFile_name());
f.setCategory(req.getCategory());
f.setDescription(req.getDescription());
f.setContent(req.getContent());
f.setFile_size(req.getFile_size() != null ? req.getFile_size() : 0L);
f.setMime_type(req.getMime_type());
f.setCompany_code(companyCode);
f.setCreated_by(userId);
knowledgeMapper.insert(f);
return knowledgeMapper.getById(f.getId());
}
@Transactional
public AiKnowledgeFile update(long id, KnowledgeFileRequest req) {
AiKnowledgeFile f = new AiKnowledgeFile();
f.setId(id);
f.setName(req.getName());
f.setFile_name(req.getFile_name());
f.setCategory(req.getCategory());
f.setDescription(req.getDescription());
f.setContent(req.getContent());
f.setFile_size(req.getFile_size());
f.setMime_type(req.getMime_type());
knowledgeMapper.update(f);
return knowledgeMapper.getById(id);
}
@Transactional
public void delete(long id) {
knowledgeMapper.delete(id);
}
}
@@ -0,0 +1,225 @@
package com.erp.ai.service;
import com.erp.ai.dto.ScheduleRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentScheduleMapper;
import com.erp.ai.model.AiAgentSchedule;
import com.erp.ai.scheduler.MultiAgentExecutionJob;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.CronExpression;
import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.springframework.beans.factory.annotation.Qualifier;
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.TimeZone;
/**
* AI 스케줄 관리 서비스 (Quartz JDBC).
* vexplor aiSchedulerService.ts 1:1 포팅.
* architecture §8.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiSchedulerService {
private static final String JOB_GROUP = "ai-agent-schedule";
private static final String TRIGGER_GROUP = "ai-agent-trigger";
private final AiAgentScheduleMapper scheduleMapper;
private final Scheduler scheduler;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
/**
* 서버 시작 시 활성 스케줄 모두 Quartz에 등록.
*/
@PostConstruct
public void initializeSchedules() {
try {
List<AiAgentSchedule> schedules = scheduleMapper.listActive();
for (AiAgentSchedule s : schedules) {
try {
registerJob(s);
} catch (Exception e) {
log.warn("스케줄 등록 실패: {} - {}", s.getName(), e.getMessage());
}
}
log.info("AI 스케줄러 초기화: {}개 활성 스케줄", schedules.size());
} catch (Exception e) {
log.warn("AI 스케줄러 초기화 실패: {}", e.getMessage());
}
}
public List<Map<String, Object>> list() {
return scheduleMapper.listAll().stream().map(this::toResponseMap).toList();
}
public Map<String, Object> getById(long id) {
AiAgentSchedule s = scheduleMapper.getById(id);
if (s == null) return null;
return toResponseMap(s);
}
/** 내부용 — model 원본 반환 (Quartz 등록 경로) */
public AiAgentSchedule getEntityById(long id) {
return scheduleMapper.getById(id);
}
private Map<String, Object> toResponseMap(AiAgentSchedule s) {
Map<String, Object> row = new HashMap<>();
row.put("id", s.getId());
row.put("name", s.getName());
row.put("group_id", s.getGroup_id());
row.put("cron_expression", s.getCron_expression());
row.put("timezone", s.getTimezone());
row.put("input_message", s.getInput_message());
row.put("notification", s.getNotification());
row.put("is_active", s.getIs_active());
row.put("last_run_at", s.getLast_run_at());
row.put("run_count", s.getRun_count());
row.put("company_code", s.getCompany_code());
row.put("created_by", s.getCreated_by());
row.put("created_at", s.getCreated_at());
row.put("updated_at", s.getUpdated_at());
row.put("group_name", s.getGroup_name());
parseJsonField(row, "notification");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgentSchedule create(ScheduleRequest req, String userId) {
if (!CronExpression.isValidExpression(req.getCron_expression())) {
throw new AiAgentException("유효하지 않은 cron 표현식입니다.");
}
AiAgentSchedule s = new AiAgentSchedule();
s.setName(req.getName());
s.setGroup_id(req.getGroup_id());
s.setCron_expression(req.getCron_expression());
s.setTimezone(req.getTimezone() != null ? req.getTimezone() : "Asia/Seoul");
s.setInput_message(req.getInput_message());
s.setNotification(toJson(req.getNotification()));
s.setIs_active(req.getIs_active() != null ? req.getIs_active() : Boolean.TRUE);
s.setCreated_by(userId);
scheduleMapper.insert(s);
AiAgentSchedule saved = scheduleMapper.getById(s.getId());
if (Boolean.TRUE.equals(saved.getIs_active())) {
try {
registerJob(saved);
} catch (Exception e) {
log.warn("Quartz 등록 실패: {}", e.getMessage());
}
}
log.info("AI 스케줄 생성: {} ({})", req.getName(), req.getCron_expression());
return saved;
}
@Transactional
public AiAgentSchedule update(long id, ScheduleRequest req) {
AiAgentSchedule existing = scheduleMapper.getById(id);
if (existing == null) return null;
if (req.getCron_expression() != null
&& !CronExpression.isValidExpression(req.getCron_expression())) {
throw new AiAgentException("유효하지 않은 cron 표현식입니다.");
}
AiAgentSchedule s = new AiAgentSchedule();
s.setId(id);
s.setName(req.getName());
s.setCron_expression(req.getCron_expression());
s.setTimezone(req.getTimezone());
s.setInput_message(req.getInput_message());
if (req.getNotification() != null) s.setNotification(toJson(req.getNotification()));
s.setIs_active(req.getIs_active());
scheduleMapper.update(s);
AiAgentSchedule saved = scheduleMapper.getById(id);
try {
unregisterJob(id);
if (Boolean.TRUE.equals(saved.getIs_active())) registerJob(saved);
} catch (Exception e) {
log.warn("Quartz 재등록 실패: {}", e.getMessage());
}
return saved;
}
@Transactional
public void delete(long id) {
try {
unregisterJob(id);
} catch (Exception e) {
log.warn("Quartz 해제 실패: {}", e.getMessage());
}
scheduleMapper.delete(id);
}
private void registerJob(AiAgentSchedule schedule) throws SchedulerException {
if (!Boolean.TRUE.equals(schedule.getIs_active())) return;
JobKey jobKey = new JobKey("schedule-" + schedule.getId(), JOB_GROUP);
TriggerKey triggerKey = new TriggerKey("trigger-" + schedule.getId(), TRIGGER_GROUP);
JobDetail jobDetail = JobBuilder.newJob(MultiAgentExecutionJob.class)
.withIdentity(jobKey)
.usingJobData("scheduleId", schedule.getId())
.storeDurably()
.build();
TimeZone tz = TimeZone.getTimeZone(schedule.getTimezone() != null
? schedule.getTimezone() : "Asia/Seoul");
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerKey)
.forJob(jobKey)
.withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCron_expression())
.inTimeZone(tz))
.build();
if (scheduler.checkExists(jobKey)) scheduler.deleteJob(jobKey);
scheduler.scheduleJob(jobDetail, trigger);
}
private void unregisterJob(long scheduleId) throws SchedulerException {
JobKey jobKey = new JobKey("schedule-" + scheduleId, JOB_GROUP);
if (scheduler.checkExists(jobKey)) {
scheduler.deleteJob(jobKey);
}
}
private String toJson(Object value) {
if (value == null) return "{}";
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
return "{}";
}
}
}
@@ -0,0 +1,439 @@
package com.erp.ai.service;
import com.erp.ai.client.LlmClient;
import com.erp.ai.client.LlmClientFactory;
import com.erp.ai.dto.GroupExecutionResult;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentMapper;
import com.erp.ai.model.AiAgent;
import com.erp.ai.model.AiAgentConversation;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentGroupMember;
import com.erp.ai.model.AiAgentUsageLog;
import com.erp.ai.model.AiAnalysisLog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
/**
* 멀티 에이전트 실행 엔진 (★ 핵심).
* vexplor multiAgentExecutionEngine.ts 1:1 포팅.
*
* - sequential: 1→2→3 순차, 이전 결과를 다음에 전달
* - parallel: 전체 동시 실행
* - mixed: execution_order 같으면 병렬, 다르면 순차
*
* architecture §7.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MultiAgentExecutionEngine {
private final AiAgentGroupService groupService;
private final AiAgentMapper agentMapper;
private final AiAgentConversationService conversationService;
private final AiAgentUsageService usageService;
private final AiAnalysisLogService analysisLogService;
private final LlmClientFactory llmClientFactory;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
@Qualifier("aiAgentExecutor")
private final ExecutorService aiAgentExecutor;
/**
* 그룹 실행 entry point.
*/
public GroupExecutionResult execute(long groupId, String userMessage, String userId, Long apiKeyId) {
AiAgentGroup group = groupService.getEntityById(groupId);
if (group == null) throw new AiAgentException("멀티 에이전트 그룹을 찾을 수 없습니다.");
List<AiAgentGroupMember> members = groupService.listMembers(groupId);
if (members.isEmpty()) throw new AiAgentException("그룹에 에이전트가 없습니다.");
String executionMode = group.getExecution_mode() != null ? group.getExecution_mode() : "mixed";
long startTime = System.currentTimeMillis();
log.info("멀티 에이전트 실행 시작: {} ({}) - \"{}\"", group.getName(), executionMode,
userMessage.length() > 50 ? userMessage.substring(0, 50) + "..." : userMessage);
// 과거 분석 이력 컨텍스트 (try/catch — 이력 없으면 무시)
String historyContext = "";
try {
List<AiAnalysisLog> recentLogs = analysisLogService.getRecentLogs(groupId, 30, 5);
if (!recentLogs.isEmpty()) {
StringBuilder sb = new StringBuilder("\n[과거 분석 이력 (최근 5건)]:\n");
for (AiAnalysisLog log : recentLogs) {
String date = log.getCreated_at() != null
? log.getCreated_at().format(DateTimeFormatter.ISO_LOCAL_DATE)
: "";
String preview = log.getAnalysis_result() != null && log.getAnalysis_result().length() > 200
? log.getAnalysis_result().substring(0, 200) + "..."
: log.getAnalysis_result();
sb.append("- ").append(date).append(": ").append(preview).append("\n");
}
historyContext = sb.toString();
}
BigDecimal accuracy = analysisLogService.getAverageAccuracy(groupId);
if (accuracy != null && accuracy.compareTo(BigDecimal.ZERO) > 0) {
historyContext += "\n평균 예측 정확도: " + accuracy.setScale(1, java.math.RoundingMode.HALF_UP) + "%";
}
} catch (Exception ignored) {
// 이력 조회 실패는 무시
}
String enrichedMessage = historyContext.isEmpty() ? userMessage : userMessage + "\n\n" + historyContext;
List<ExecutionStepResult> stepResults;
switch (executionMode) {
case "parallel" -> stepResults = executeParallel(members, enrichedMessage, "");
case "sequential" -> stepResults = executeSequential(members, enrichedMessage);
default -> stepResults = executeMixed(members, enrichedMessage);
}
String finalSummary = buildFinalSummary(stepResults, userMessage);
long totalTokens = stepResults.stream().mapToLong(ExecutionStepResult::tokensUsed).sum();
long totalDuration = System.currentTimeMillis() - startTime;
// 사이드 이펙트 — 별도 트랜잭션 (REQUIRES_NEW)
try {
persistResults(group, executionMode, userMessage, stepResults,
finalSummary, totalTokens, totalDuration, userId, apiKeyId);
} catch (Exception e) {
log.warn("멀티 에이전트 결과 적재 실패: {}", e.getMessage());
}
log.info("멀티 에이전트 실행 완료: {} - {} tokens, {}ms", group.getName(), totalTokens, totalDuration);
// GroupExecutionResult 매핑
List<Map<String, Object>> stepsOut = new ArrayList<>();
for (ExecutionStepResult r : stepResults) {
Map<String, Object> m = new HashMap<>();
m.put("order", r.executionOrder());
m.put("role", r.roleName());
m.put("agent", r.agentName());
m.put("model", r.modelName());
m.put("response", r.response());
m.put("tokens", r.tokensUsed());
m.put("duration_ms", r.durationMs());
m.put("memberId", r.memberId());
m.put("connectorResults", r.connectorResults());
stepsOut.add(m);
}
return GroupExecutionResult.builder()
.groupId(group.getId())
.groupName(group.getName())
.executionMode(executionMode)
.steps(stepsOut)
.finalSummary(finalSummary)
.totalTokens(totalTokens)
.totalDurationMs(totalDuration)
.build();
}
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 30)
public void persistResults(AiAgentGroup group, String executionMode, String userMessage,
List<ExecutionStepResult> stepResults, String finalSummary,
long totalTokens, long totalDuration, String userId, Long apiKeyId) {
// 대화 저장
try {
AiAgentConversation conv = conversationService.createConversation(null, userId, apiKeyId);
String title = "[" + group.getName() + "] "
+ (userMessage.length() > 100 ? userMessage.substring(0, 100) : userMessage);
Map<String, Object> meta = Map.of(
"group_id", group.getId(),
"group_name", group.getName(),
"execution_mode", executionMode);
conversationService.updateMeta(conv.getId(), title, toJson(meta));
conversationService.addMessage(conv.getId(), "user", userMessage, 0, null);
for (ExecutionStepResult step : stepResults) {
Map<String, Object> stepMeta = Map.of(
"role_name", step.roleName(),
"agent_name", step.agentName(),
"model_name", step.modelName(),
"execution_order", step.executionOrder(),
"duration_ms", step.durationMs());
conversationService.addMessageWithMetadata(conv.getId(), "assistant",
"[" + step.roleName() + " - " + step.agentName() + "]\n" + step.response(),
(int) step.tokensUsed(), toJson(stepMeta));
}
} catch (Exception e) {
log.warn("멀티 에이전트 대화 저장 실패: {}", e.getMessage());
}
// 분석 이력 저장
try {
AiAnalysisLog analysis = new AiAnalysisLog();
analysis.setGroup_id(group.getId());
analysis.setExecution_type(apiKeyId != null ? "api" : "manual");
analysis.setInput_message(userMessage);
analysis.setAnalysis_result(finalSummary);
analysis.setTokens_used((int) totalTokens);
analysis.setDuration_ms((int) totalDuration);
analysis.setCompany_code(group.getCompany_code());
analysisLogService.save(analysis);
} catch (Exception e) {
log.warn("분석 이력 저장 실패: {}", e.getMessage());
}
// 사용량 로깅
try {
AiAgentUsageLog usage = new AiAgentUsageLog();
usage.setUser_id(userId);
usage.setApi_key_id(apiKeyId);
usage.setTotal_tokens((int) totalTokens);
usage.setResponse_time_ms((int) totalDuration);
usage.setSuccess(true);
usage.setRequest_path("/groups/" + group.getId());
usage.setCompany_code(group.getCompany_code());
usageService.log(usage);
} catch (Exception e) {
log.warn("사용량 로그 실패: {}", e.getMessage());
}
}
private List<ExecutionStepResult> executeSequential(List<AiAgentGroupMember> members, String userMessage) {
List<AiAgentGroupMember> sorted = new ArrayList<>(members);
sorted.sort((a, b) -> Integer.compare(a.getExecution_order(), b.getExecution_order()));
List<ExecutionStepResult> results = new ArrayList<>();
StringBuilder previousContext = new StringBuilder();
for (AiAgentGroupMember m : sorted) {
ExecutionStepResult r = executeSingleAgent(m, userMessage, previousContext.toString());
results.add(r);
previousContext.append("\n[").append(m.getRole_name()).append(" 결과]:\n")
.append(r.response()).append("\n");
}
return results;
}
private List<ExecutionStepResult> executeParallel(List<AiAgentGroupMember> members,
String userMessage, String previousContext) {
List<CompletableFuture<ExecutionStepResult>> futures = new ArrayList<>();
for (AiAgentGroupMember m : members) {
futures.add(CompletableFuture.supplyAsync(
() -> executeSingleAgent(m, userMessage, previousContext), aiAgentExecutor));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<ExecutionStepResult> out = new ArrayList<>();
for (CompletableFuture<ExecutionStepResult> f : futures) {
out.add(f.join());
}
return out;
}
private List<ExecutionStepResult> executeMixed(List<AiAgentGroupMember> members, String userMessage) {
TreeMap<Integer, List<AiAgentGroupMember>> orderGroups = new TreeMap<>();
for (AiAgentGroupMember m : members) {
orderGroups.computeIfAbsent(m.getExecution_order(), k -> new ArrayList<>()).add(m);
}
List<ExecutionStepResult> all = new ArrayList<>();
StringBuilder previousContext = new StringBuilder();
for (Map.Entry<Integer, List<AiAgentGroupMember>> entry : orderGroups.entrySet()) {
List<AiAgentGroupMember> stage = entry.getValue();
List<ExecutionStepResult> stageResults;
if (stage.size() == 1) {
stageResults = List.of(executeSingleAgent(stage.get(0), userMessage, previousContext.toString()));
} else {
stageResults = executeParallel(stage, userMessage, previousContext.toString());
}
all.addAll(stageResults);
for (ExecutionStepResult r : stageResults) {
previousContext.append("\n[").append(r.roleName()).append(" 결과]:\n")
.append(r.response()).append("\n");
}
}
return all;
}
@SuppressWarnings("unchecked")
private ExecutionStepResult executeSingleAgent(AiAgentGroupMember member,
String userMessage, String previousContext) {
long startTime = System.currentTimeMillis();
AiAgent agent = agentMapper.getById(member.getAgent_id());
if (agent == null) {
return new ExecutionStepResult(member.getId(), member.getRole_name(),
"알 수 없음", "unknown", member.getExecution_order(),
"에이전트를 찾을 수 없습니다.", 0L, System.currentTimeMillis() - startTime, List.of());
}
// 커넥터 데이터 수집 (invyone에는 외부 커넥터 테이블 부재 — info 만 반환)
List<Map<String, Object>> connectorResults = new ArrayList<>();
StringBuilder connectorContext = new StringBuilder();
List<Map<String, Object>> connectors = parseConnectors(member.getConnectors());
for (Map<String, Object> c : connectors) {
Map<String, Object> data = executeConnectorPlaceholder(c);
Map<String, Object> entry = new HashMap<>();
entry.put("connector", c.get("name"));
entry.put("type", c.get("type"));
entry.put("data", data);
connectorResults.add(entry);
connectorContext.append("\n[데이터 소스: ").append(c.get("name"))
.append(" (").append(c.get("type")).append(")]:\n")
.append(safeJson(data)).append("\n");
}
// 지식 파일 컨텍스트 (config.knowledge_files)
String knowledgeContext = buildKnowledgeContext(agent);
String systemPrompt = (agent.getSystem_prompt() != null
? agent.getSystem_prompt() : "당신은 도움이 되는 AI 어시스턴트입니다.")
+ "\n당신의 역할: " + member.getRole_name()
+ knowledgeContext
+ (connectorContext.length() > 0 ? "\n사용 가능한 데이터:\n" + connectorContext : "")
+ (previousContext != null && !previousContext.isEmpty()
? "\n이전 에이전트들의 분석 결과:\n" + previousContext : "");
// LLM 호출
try {
Map<String, Object> agentConfig = parseConfig(agent.getConfig());
Map<String, Object> llmRequest = new HashMap<>();
llmRequest.put("model", agent.getModel());
llmRequest.put("messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userMessage)
));
llmRequest.put("max_tokens", agentConfig.getOrDefault("max_tokens", 2000));
llmRequest.put("temperature", agentConfig.getOrDefault("temperature", 0.7));
LlmClient client = llmClientFactory.pick(agent.getModel());
Map<String, Object> result = client.chat(llmRequest);
String text = "응답 없음";
long tokens = 0L;
List<Map<String, Object>> choices = (List<Map<String, Object>>) result.getOrDefault("choices", List.of());
if (!choices.isEmpty()) {
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
if (message != null) {
Object content = message.get("content");
if (content != null) text = String.valueOf(content);
}
}
Map<String, Object> usage = (Map<String, Object>) result.get("usage");
if (usage != null && usage.get("total_tokens") instanceof Number n) {
tokens = n.longValue();
}
return new ExecutionStepResult(member.getId(), member.getRole_name(),
agent.getName(), agent.getModel(), member.getExecution_order(),
text, tokens, System.currentTimeMillis() - startTime, connectorResults);
} catch (Exception e) {
log.warn("에이전트 실행 실패 ({}): {}", member.getRole_name(), e.getMessage());
return new ExecutionStepResult(member.getId(), member.getRole_name(),
agent.getName(), agent.getModel(), member.getExecution_order(),
"[실행 실패] " + e.getMessage(), 0L,
System.currentTimeMillis() - startTime, connectorResults);
}
}
@SuppressWarnings("unchecked")
private String buildKnowledgeContext(AiAgent agent) {
try {
Map<String, Object> config = parseConfig(agent.getConfig());
Object kfRaw = config.get("knowledge_files");
if (!(kfRaw instanceof List<?> rawList) || rawList.isEmpty()) return "";
StringBuilder out = new StringBuilder("\n[참고 지식 문서]:\n");
for (Object item : rawList) {
if (!(item instanceof Map<?, ?> rawMap)) continue;
Map<String, Object> mapItem = (Map<String, Object>) rawMap;
Object nameObj = mapItem.get("name");
String name = nameObj != null ? String.valueOf(nameObj) : "";
Object content = mapItem.get("content");
if (content != null) {
String c = String.valueOf(content);
out.append("--- ").append(name).append(" ---\n")
.append(c.length() > 10000 ? c.substring(0, 10000) : c).append("\n\n");
}
// library_id resolution은 KnowledgeService 의존성 회피 위해 생략 (향후 통합)
}
return out.toString();
} catch (Exception e) {
return "";
}
}
private Map<String, Object> executeConnectorPlaceholder(Map<String, Object> connector) {
String type = String.valueOf(connector.getOrDefault("type", ""));
String name = String.valueOf(connector.getOrDefault("name", ""));
return Map.of("type", type, "name", name, "info", "커넥터 연결 준비됨");
}
private List<Map<String, Object>> parseConnectors(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (JsonProcessingException e) {
return List.of();
}
}
private Map<String, Object> parseConfig(String json) {
if (json == null || json.isBlank()) return new HashMap<>();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (JsonProcessingException e) {
return new HashMap<>();
}
}
private String safeJson(Object value) {
try {
String s = objectMapper.writeValueAsString(value);
return s.length() > 2000 ? s.substring(0, 2000) : s;
} catch (JsonProcessingException e) {
return String.valueOf(value);
}
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
return "{}";
}
}
private String buildFinalSummary(List<ExecutionStepResult> results, String originalQuestion) {
StringBuilder sb = new StringBuilder("질문: ").append(originalQuestion).append("\n\n");
for (int i = 0; i < results.size(); i++) {
ExecutionStepResult r = results.get(i);
sb.append("[").append(r.roleName()).append(" (").append(r.agentName()).append(")]:\n")
.append(r.response());
if (i < results.size() - 1) sb.append("\n\n---\n\n");
}
return sb.toString();
}
/**
* 단일 에이전트 실행 결과.
*/
public record ExecutionStepResult(
long memberId,
String roleName,
String agentName,
String modelName,
int executionOrder,
String response,
long tokensUsed,
long durationMs,
List<Map<String, Object>> connectorResults
) {}
}
@@ -0,0 +1,129 @@
package com.erp.ai.service;
import com.erp.ai.client.OpenClawClient;
import com.erp.ai.config.OpenClawProperties;
import com.erp.ai.exception.OpenClawException;
import com.erp.ai.mapper.AiAgentMapper;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiAgent;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Pipeline DB → OpenClaw config 동기화.
* vexplor openClawSyncService.ts 1:1 포팅 (HTTP API 방식).
* OpenClaw 가 비활성(enabled=false) 이면 모든 메서드는 no-op.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OpenClawSyncService {
private final AiLlmProviderMapper providerMapper;
private final AiAgentMapper agentMapper;
private final AesGcmCipher cipher;
private final OpenClawClient openClawClient;
private final OpenClawProperties openClawProperties;
/**
* LLM 프로바이더를 OpenClaw auth profiles 로 동기화.
*/
public void syncProviders() {
if (!openClawProperties.isEnabled()) {
log.debug("OpenClaw 비활성 — providers sync skip");
return;
}
try {
List<AiLlmProvider> providers = providerMapper.listActive();
Map<String, Object> authProfiles = new HashMap<>();
for (AiLlmProvider p : providers) {
String decryptedKey = cipher.decrypt(p.getApi_key_encrypted());
String profileKey = "pipeline-" + p.getName() + "-" + p.getId();
Map<String, Object> profile = buildAuthProfile(p, decryptedKey);
if (profile != null) authProfiles.put(profileKey, profile);
}
Map<String, Object> body = new HashMap<>();
body.put("authProfiles", authProfiles);
if (!providers.isEmpty()) {
AiLlmProvider primary = providers.get(0);
Map<String, Object> models = new HashMap<>();
models.put("default", primary.getName() + ":" + primary.getModel_name());
models.put("authProfile", "pipeline-" + primary.getName() + "-" + primary.getId());
body.put("models", models);
}
openClawClient.syncAuthProfiles(body);
log.info("OpenClaw 프로바이더 동기화: {}개", providers.size());
} catch (OpenClawException e) {
log.warn("OpenClaw 프로바이더 동기화 실패 (graceful): {}", e.getMessage());
} catch (Exception e) {
log.error("OpenClaw 프로바이더 동기화 오류", e);
}
}
/**
* 에이전트를 OpenClaw agents 로 동기화.
*/
public void syncAgents() {
if (!openClawProperties.isEnabled()) {
log.debug("OpenClaw 비활성 — agents sync skip");
return;
}
try {
List<AiAgent> agents = agentMapper.getActiveAgents();
Map<String, Object> clawAgents = new HashMap<>();
for (AiAgent agent : agents) {
Map<String, Object> entry = new HashMap<>();
entry.put("displayName", agent.getName());
entry.put("description", agent.getDescription() != null ? agent.getDescription() : "");
entry.put("model", agent.getModel());
entry.put("systemPrompt", agent.getSystem_prompt() != null ? agent.getSystem_prompt() : "");
entry.put("tools", agent.getTools());
entry.put("config", agent.getConfig());
clawAgents.put(agent.getAgent_id(), entry);
}
openClawClient.syncAgents(Map.of("agents", clawAgents));
log.info("OpenClaw 에이전트 동기화: {}개", agents.size());
} catch (OpenClawException e) {
log.warn("OpenClaw 에이전트 동기화 실패 (graceful): {}", e.getMessage());
} catch (Exception e) {
log.error("OpenClaw 에이전트 동기화 오류", e);
}
}
public void syncAll() {
syncProviders();
syncAgents();
}
private Map<String, Object> buildAuthProfile(AiLlmProvider p, String decryptedKey) {
Map<String, Object> profile = new HashMap<>();
switch (p.getName()) {
case "anthropic", "openai", "google" -> {
profile.put("provider", p.getName());
profile.put("apiKey", decryptedKey);
}
case "deepseek" -> {
profile.put("provider", "openai-compat");
profile.put("apiKey", decryptedKey);
profile.put("baseUrl", p.getEndpoint() != null ? p.getEndpoint() : "https://api.deepseek.com/v1");
}
case "ollama" -> {
profile.put("provider", "ollama");
profile.put("baseUrl", p.getEndpoint() != null ? p.getEndpoint() : "http://localhost:11434");
}
default -> {
return null;
}
}
return profile;
}
}
@@ -0,0 +1,93 @@
package com.erp.ai.util;
import com.erp.ai.exception.AiAgentException;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/**
* LLM API 키 AES-256-GCM 암복호화 유틸.
* architecture §5.3 — base64( IV(12B) || CIPHERTEXT || TAG(16B) )
*
* 키는 application.yml ai.encryption-key (Base64 32B)에서 주입.
*/
@Slf4j
@Component
public class AesGcmCipher {
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private final String encodedKey;
private SecretKey secretKey;
private final SecureRandom secureRandom = new SecureRandom();
public AesGcmCipher(@Value("${ai.encryption-key}") String encodedKey) {
this.encodedKey = encodedKey;
}
@PostConstruct
public void init() {
try {
byte[] keyBytes = Base64.getDecoder().decode(encodedKey);
if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) {
// 잘못된 길이인 경우 SHA-256 으로 32 byte derived 키 사용
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
keyBytes = md.digest(encodedKey.getBytes(StandardCharsets.UTF_8));
}
this.secretKey = new SecretKeySpec(keyBytes, "AES");
} catch (Exception e) {
log.error("AES-GCM 키 초기화 실패: {}", e.getMessage());
throw new AiAgentException("암호화 키 초기화 실패", e);
}
}
public String encrypt(String plain) {
if (plain == null) return null;
try {
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] cipherText = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
ByteBuffer buf = ByteBuffer.allocate(IV_LENGTH + cipherText.length);
buf.put(iv).put(cipherText);
return Base64.getEncoder().encodeToString(buf.array());
} catch (Exception e) {
throw new AiAgentException("암호화 실패: " + e.getMessage(), e);
}
}
public String decrypt(String cipherTextB64) {
if (cipherTextB64 == null) return null;
try {
byte[] all = Base64.getDecoder().decode(cipherTextB64);
if (all.length < IV_LENGTH + 16) {
throw new AiAgentException("암호문 길이가 잘못되었습니다");
}
byte[] iv = new byte[IV_LENGTH];
byte[] ct = new byte[all.length - IV_LENGTH];
System.arraycopy(all, 0, iv, 0, IV_LENGTH);
System.arraycopy(all, IV_LENGTH, ct, 0, ct.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] plain = cipher.doFinal(ct);
return new String(plain, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new AiAgentException("복호화 실패: " + e.getMessage(), e);
}
}
}
@@ -1,8 +1,9 @@
package com.erp.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -13,6 +14,11 @@ public class JacksonConfig {
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
// Java 8 date/time (OffsetDateTime, LocalDateTime 등) 직렬화 지원.
// 커스텀 ObjectMapper 빈이 있으면 Spring Boot 자동 구성 모듈 등록이 적용되지
// 않으므로 명시적으로 등록한다.
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
@@ -1,44 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.AiAssistantProxyService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@Slf4j
public class AiAssistantProxyController {
private final AiAssistantProxyService aiAssistantProxyService;
@GetMapping("/api/ai/v1/status")
public ResponseEntity<ApiResponse<Map<String, Object>>> getStatus() {
Map<String, Object> result = new HashMap<>();
result.put("service_url", "http://127.0.0.1:3100");
result.put("status", "ok");
result.put("message", "AI 어시스턴트 프록시 서비스가 구성되었습니다.");
return ResponseEntity.ok(ApiResponse.success(result));
}
@RequestMapping(
value = "/api/ai/v1/**",
method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
RequestMethod.DELETE, RequestMethod.PATCH}
)
public ResponseEntity<byte[]> proxy(
HttpServletRequest request,
HttpHeaders headers,
@RequestBody(required = false) byte[] body) {
String path = request.getRequestURI().replaceFirst("^/api/ai/v1", "");
String query = request.getQueryString();
HttpMethod method = HttpMethod.valueOf(request.getMethod());
return aiAssistantProxyService.forward(path, query, method, headers, body);
}
}
@@ -1,5 +1,7 @@
package com.erp.security;
import com.erp.ai.security.AiApiKeyAuthFilter;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.SubdomainResolverFilter;
import com.erp.tenant.TenantDbSettings;
@@ -34,9 +36,10 @@ public class SecurityConfig {
private final CompanyResolver companyResolver;
private final TenantRoutingDataSource tenantRoutingDataSource;
private final TenantDbSettings tenantDbSettings;
private final AiAgentApiKeyService aiAgentApiKeyService;
/**
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com").
* application.yml 또는 환경변수 CORS_ALLOWED_ORIGINS 로 설정.
*/
@Value("${cors.allowed-origins}")
@@ -56,19 +59,26 @@ public class SecurityConfig {
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
)
// ⚠️ Spring Security 6 부터 addFilterBefore/After 의 anchor 는 _이미 등록된_
// 필터여야 함. 따라서 JwtAuthenticationFilter 를 가장 먼저 (Spring 표준
// UsernamePasswordAuthenticationFilter 기준으로) 등록한 뒤, 나머지 커스텀
// 필터들이 JwtAuthenticationFilter 를 anchor 로 사용한다.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
.addFilterBefore(
new SubdomainResolverFilter(companyResolver, tenantRoutingDataSource, tenantDbSettings),
JwtAuthenticationFilter.class)
// AiApi 키 인증 — jwt 앞에서 sk-pipe-* 형식 처리, 매칭되지 않으면 jwt 로 통과.
.addFilterBefore(new AiApiKeyAuthFilter(aiAgentApiKeyService),
JwtAuthenticationFilter.class)
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
JwtAuthenticationFilter.class)
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
TenantConsistencyGuardFilter.class)
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
.addFilterBefore(
new SubdomainResolverFilter(companyResolver, tenantRoutingDataSource, tenantDbSettings),
JwtAuthenticationFilter.class);
TenantConsistencyGuardFilter.class);
return http.build();
}
@@ -1,66 +0,0 @@
package com.erp.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.*;
@Service
@Slf4j
public class AiAssistantProxyService {
private static final String AI_SERVICE_BASE =
System.getenv().getOrDefault("AI_ASSISTANT_SERVICE_URL", "http://127.0.0.1:3100");
private final ObjectMapper objectMapper = new ObjectMapper();
public ResponseEntity<byte[]> forward(String path, String query,
HttpMethod method, HttpHeaders incomingHeaders,
byte[] body) {
String url = AI_SERVICE_BASE + "/api/v1" + path;
if (query != null && !query.isEmpty()) {
url += "?" + query;
}
try {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
MediaType contentType = incomingHeaders.getContentType();
if (contentType != null) headers.setContentType(contentType);
String auth = incomingHeaders.getFirst(HttpHeaders.AUTHORIZATION);
if (auth != null) headers.set(HttpHeaders.AUTHORIZATION, auth);
String accept = incomingHeaders.getFirst(HttpHeaders.ACCEPT);
if (accept != null) headers.set(HttpHeaders.ACCEPT, accept);
RequestEntity<byte[]> requestEntity = new RequestEntity<>(body, headers, method, URI.create(url));
return restTemplate.exchange(requestEntity, byte[].class);
} catch (ResourceAccessException e) {
log.warn("AI service unavailable at {}: {}", url, e.getMessage());
return buildServiceUnavailableResponse();
} catch (Exception e) {
log.error("AI proxy error: {}", e.getMessage());
return buildServiceUnavailableResponse();
}
}
private ResponseEntity<byte[]> buildServiceUnavailableResponse() {
try {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "AI_SERVICE_UNAVAILABLE");
errorDetail.put("message", "AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.");
error.put("error", errorDetail);
byte[] body = objectMapper.writeValueAsBytes(error);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.contentType(MediaType.APPLICATION_JSON)
.body(body);
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
}
}
@@ -29,6 +29,9 @@ public class TemplateService extends BaseService {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getTemplateList", params);
for (Map<String, Object> row : list) {
parseJsonField(row, "views");
}
return commonService.buildListResponse(list, totalCount, params);
}
@@ -24,14 +24,29 @@ spring:
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
out-of-order: true # 누락된 낮은 버전 마이그레이션도 적용 허용 (V015 이전 누락 케이스 안전망)
locations: classpath:db/migration
schemas: public
table: flyway_schema_history
mybatis:
mapper-locations: classpath:mapper/*.xml
mapper-locations: classpath:mapper/*.xml,classpath:mapper/ai/*.xml
configuration:
map-underscore-to-camel-case: false
default-fetch-size: 100
default-statement-timeout: 30
ai:
encryption-key: ${AI_PROVIDER_ENCRYPTION_KEY:dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcy1iYXNlNjQh}
default-timezone: ${AI_DEFAULT_TIMEZONE:Asia/Seoul}
rate-limit-per-minute: ${AI_RATE_LIMIT_PER_MINUTE:60}
llm-http-timeout-sec: ${LLM_HTTP_TIMEOUT_SEC:60}
quartz-clustered: ${AI_QUARTZ_CLUSTERED:false}
jwt:
# JWT_SECRET 환경변수 필수. 디폴트 없음 — 미지정 시 앱 기동 실패 (의도된 동작)
# 새 secret 생성: openssl rand -base64 64 | tr -d '\n='
@@ -48,6 +63,12 @@ cors:
file:
upload-dir: ./uploads
openclaw:
enabled: ${OPENCLAW_ENABLED:false}
gateway-url: ${OPENCLAW_GATEWAY_URL:http://localhost:18789}
timeout: 60s
health-check-interval: 30s
logging:
level:
com.erp: DEBUG
@@ -0,0 +1,23 @@
-- V001: ai_llm_providers
-- LLM 프로바이더 테이블 (anthropic/openai/google/deepseek/ollama)
CREATE TABLE ai_llm_providers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(200) NOT NULL,
api_key_encrypted TEXT NOT NULL,
model_name VARCHAR(100) NOT NULL,
endpoint VARCHAR(500),
priority INTEGER NOT NULL DEFAULT 1,
max_tokens INTEGER NOT NULL DEFAULT 4096,
temperature NUMERIC(3,2) NOT NULL DEFAULT 0.70,
cost_per_1k_input NUMERIC(10,6) NOT NULL DEFAULT 0,
cost_per_1k_output NUMERIC(10,6) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_providers_priority ON ai_llm_providers (priority);
CREATE INDEX idx_providers_active ON ai_llm_providers (is_active);
@@ -0,0 +1,23 @@
-- V002: ai_agents
-- AI 에이전트 테이블
CREATE TABLE ai_agents (
id BIGSERIAL PRIMARY KEY,
agent_id VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
model VARCHAR(100) NOT NULL DEFAULT 'claude-sonnet-4-20250514',
system_prompt TEXT,
tools JSONB NOT NULL DEFAULT '[]'::jsonb,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','archived')),
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ai_agents_company ON ai_agents (company_code);
CREATE INDEX idx_ai_agents_status ON ai_agents (status);
CREATE INDEX idx_ai_agents_created ON ai_agents (created_at DESC);
@@ -0,0 +1,20 @@
-- V003: ai_agent_groups
-- AI 에이전트 그룹 테이블
CREATE TABLE ai_agent_groups (
id BIGSERIAL PRIMARY KEY,
group_id VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
execution_mode VARCHAR(20) NOT NULL DEFAULT 'mixed'
CHECK (execution_mode IN ('parallel','sequential','mixed')),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','archived')),
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ai_groups_company ON ai_agent_groups (company_code);
CREATE INDEX idx_ai_groups_status ON ai_agent_groups (status);
@@ -0,0 +1,16 @@
-- V004: ai_agent_group_members
-- AI 에이전트 그룹 멤버 테이블 (execution_order 로 mixed 모드 지원)
CREATE TABLE ai_agent_group_members (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES ai_agent_groups(id) ON DELETE CASCADE,
agent_id BIGINT NOT NULL REFERENCES ai_agents(id) ON DELETE RESTRICT,
role_name VARCHAR(100) NOT NULL,
connectors JSONB NOT NULL DEFAULT '[]'::jsonb,
execution_order INTEGER NOT NULL DEFAULT 1,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_group_members_group ON ai_agent_group_members (group_id, execution_order);
@@ -0,0 +1,27 @@
-- V005: ai_agent_api_keys
-- AI 에이전트 API 키 테이블 (sk-pipe-* 형식)
CREATE TABLE ai_agent_api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
key_hash CHAR(64) NOT NULL UNIQUE,
key_prefix VARCHAR(16) NOT NULL,
user_id VARCHAR(64) NOT NULL,
company_code VARCHAR(20),
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
permissions JSONB NOT NULL DEFAULT '["chat"]'::jsonb,
rate_limit INTEGER NOT NULL DEFAULT 60,
monthly_token_limit BIGINT NOT NULL DEFAULT 1000000,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','revoked')),
last_used_at TIMESTAMPTZ,
usage_count BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_api_keys_hash ON ai_agent_api_keys (key_hash);
CREATE INDEX idx_api_keys_user ON ai_agent_api_keys (user_id);
CREATE INDEX idx_api_keys_company ON ai_agent_api_keys (company_code);
CREATE INDEX idx_api_keys_status ON ai_agent_api_keys (status);
@@ -0,0 +1,22 @@
-- V006: ai_agent_conversations
-- AI 에이전트 대화 세션 테이블
CREATE TABLE ai_agent_conversations (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL UNIQUE,
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
user_id VARCHAR(64),
api_key_id BIGINT REFERENCES ai_agent_api_keys(id) ON DELETE SET NULL,
title VARCHAR(500),
message_count INTEGER NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_conv_agent ON ai_agent_conversations (agent_id);
CREATE INDEX idx_conv_user ON ai_agent_conversations (user_id);
CREATE INDEX idx_conv_apikey ON ai_agent_conversations (api_key_id);
CREATE INDEX idx_conv_updated ON ai_agent_conversations (updated_at DESC);
@@ -0,0 +1,15 @@
-- V007: ai_agent_messages
-- AI 에이전트 대화 메시지 테이블
CREATE TABLE ai_agent_messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES ai_agent_conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('system','user','assistant','tool')),
content TEXT NOT NULL,
tool_calls JSONB,
token_count INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_msg_conv ON ai_agent_messages (conversation_id, created_at);
@@ -0,0 +1,28 @@
-- V008: ai_agent_usage_logs
-- AI 에이전트 사용량 로그 테이블 (토큰/비용 추적)
CREATE TABLE ai_agent_usage_logs (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(64),
api_key_id BIGINT REFERENCES ai_agent_api_keys(id) ON DELETE SET NULL,
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
conversation_id BIGINT REFERENCES ai_agent_conversations(id) ON DELETE SET NULL,
provider_name VARCHAR(50),
model_name VARCHAR(100),
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6) NOT NULL DEFAULT 0,
response_time_ms INTEGER,
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
request_path VARCHAR(500),
ip_address INET,
company_code VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_usage_created ON ai_agent_usage_logs (created_at DESC);
CREATE INDEX idx_usage_apikey ON ai_agent_usage_logs (api_key_id, created_at DESC);
CREATE INDEX idx_usage_agent ON ai_agent_usage_logs (agent_id, created_at DESC);
CREATE INDEX idx_usage_company ON ai_agent_usage_logs (company_code, created_at DESC);
@@ -0,0 +1,22 @@
-- V009: ai_agent_schedules
-- AI 에이전트 스케줄 테이블 (Quartz JDBC 연동)
CREATE TABLE ai_agent_schedules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
group_id BIGINT NOT NULL REFERENCES ai_agent_groups(id) ON DELETE CASCADE,
cron_expression VARCHAR(100) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Seoul',
input_message TEXT NOT NULL,
notification JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
run_count BIGINT NOT NULL DEFAULT 0,
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sched_active ON ai_agent_schedules (is_active);
CREATE INDEX idx_sched_company ON ai_agent_schedules (company_code);
@@ -0,0 +1,24 @@
-- V010: ai_analysis_logs
-- AI 분석 실행 이력 테이블 (정확도 추적 포함)
CREATE TABLE ai_analysis_logs (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES ai_agent_groups(id) ON DELETE SET NULL,
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
schedule_id BIGINT REFERENCES ai_agent_schedules(id) ON DELETE SET NULL,
execution_type VARCHAR(20) NOT NULL
CHECK (execution_type IN ('manual','api','schedule')),
input_message TEXT NOT NULL,
analysis_result TEXT NOT NULL,
prediction JSONB,
actual_result JSONB,
accuracy_score NUMERIC(5,2),
tokens_used INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
company_code VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_analysis_group_created ON ai_analysis_logs (group_id, created_at DESC);
CREATE INDEX idx_analysis_company ON ai_analysis_logs (company_code, created_at DESC);
@@ -0,0 +1,20 @@
-- V011: ai_knowledge_files
-- AI 지식 파일 테이블 (RAG 문서 저장)
CREATE TABLE ai_knowledge_files (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300) NOT NULL,
file_name VARCHAR(300),
category VARCHAR(100),
description TEXT,
content TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
mime_type VARCHAR(100),
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_knowledge_category ON ai_knowledge_files (category);
CREATE INDEX idx_knowledge_company ON ai_knowledge_files (company_code);
@@ -0,0 +1,156 @@
-- V012: Quartz JDBC JobStore 영구 테이블 (PostgreSQL)
-- 출처: spring-boot-starter-quartz / quartz tables_postgres.sql
CREATE TABLE QRTZ_JOB_DETAILS (
SCHED_NAME VARCHAR(120) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250),
JOB_CLASS_NAME VARCHAR(250) NOT NULL,
IS_DURABLE BOOLEAN NOT NULL,
IS_NONCONCURRENT BOOLEAN NOT NULL,
IS_UPDATE_DATA BOOLEAN NOT NULL,
REQUESTS_RECOVERY BOOLEAN NOT NULL,
JOB_DATA BYTEA,
CONSTRAINT PK_QRTZ_JOB_DETAILS PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
);
CREATE TABLE QRTZ_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250),
NEXT_FIRE_TIME BIGINT,
PREV_FIRE_TIME BIGINT,
PRIORITY INTEGER,
TRIGGER_STATE VARCHAR(16) NOT NULL,
TRIGGER_TYPE VARCHAR(8) NOT NULL,
START_TIME BIGINT NOT NULL,
END_TIME BIGINT,
CALENDAR_NAME VARCHAR(200),
MISFIRE_INSTR SMALLINT,
JOB_DATA BYTEA,
CONSTRAINT PK_QRTZ_TRIGGERS PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
CONSTRAINT FK_QRTZ_TRIGGERS_JOB_DETAILS FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP)
);
CREATE TABLE QRTZ_SIMPLE_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
REPEAT_COUNT BIGINT NOT NULL,
REPEAT_INTERVAL BIGINT NOT NULL,
TIMES_TRIGGERED BIGINT NOT NULL,
CONSTRAINT PK_QRTZ_SIMPLE_TRIGGERS PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
CONSTRAINT FK_QRTZ_SIMPLE_TRIGGERS FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_CRON_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
CRON_EXPRESSION VARCHAR(120) NOT NULL,
TIME_ZONE_ID VARCHAR(80),
CONSTRAINT PK_QRTZ_CRON_TRIGGERS PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
CONSTRAINT FK_QRTZ_CRON_TRIGGERS FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_SIMPROP_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
STR_PROP_1 VARCHAR(512),
STR_PROP_2 VARCHAR(512),
STR_PROP_3 VARCHAR(512),
INT_PROP_1 INTEGER,
INT_PROP_2 INTEGER,
LONG_PROP_1 BIGINT,
LONG_PROP_2 BIGINT,
DEC_PROP_1 NUMERIC(13,4),
DEC_PROP_2 NUMERIC(13,4),
BOOL_PROP_1 BOOLEAN,
BOOL_PROP_2 BOOLEAN,
CONSTRAINT PK_QRTZ_SIMPROP_TRIGGERS PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
CONSTRAINT FK_QRTZ_SIMPROP_TRIGGERS FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_BLOB_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
BLOB_DATA BYTEA,
CONSTRAINT PK_QRTZ_BLOB_TRIGGERS PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
CONSTRAINT FK_QRTZ_BLOB_TRIGGERS FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_CALENDARS (
SCHED_NAME VARCHAR(120) NOT NULL,
CALENDAR_NAME VARCHAR(200) NOT NULL,
CALENDAR BYTEA NOT NULL,
CONSTRAINT PK_QRTZ_CALENDARS PRIMARY KEY (SCHED_NAME, CALENDAR_NAME)
);
CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS (
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
CONSTRAINT PK_QRTZ_PAUSED_TRIGGER_GRPS PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_FIRED_TRIGGERS (
SCHED_NAME VARCHAR(120) NOT NULL,
ENTRY_ID VARCHAR(95) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
FIRED_TIME BIGINT NOT NULL,
SCHED_TIME BIGINT NOT NULL,
PRIORITY INTEGER NOT NULL,
STATE VARCHAR(16) NOT NULL,
JOB_NAME VARCHAR(200),
JOB_GROUP VARCHAR(200),
IS_NONCONCURRENT BOOLEAN,
REQUESTS_RECOVERY BOOLEAN,
CONSTRAINT PK_QRTZ_FIRED_TRIGGERS PRIMARY KEY (SCHED_NAME, ENTRY_ID)
);
CREATE TABLE QRTZ_SCHEDULER_STATE (
SCHED_NAME VARCHAR(120) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
LAST_CHECKIN_TIME BIGINT NOT NULL,
CHECKIN_INTERVAL BIGINT NOT NULL,
CONSTRAINT PK_QRTZ_SCHEDULER_STATE PRIMARY KEY (SCHED_NAME, INSTANCE_NAME)
);
CREATE TABLE QRTZ_LOCKS (
SCHED_NAME VARCHAR(120) NOT NULL,
LOCK_NAME VARCHAR(40) NOT NULL,
CONSTRAINT PK_QRTZ_LOCKS PRIMARY KEY (SCHED_NAME, LOCK_NAME)
);
CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY ON QRTZ_JOB_DETAILS (SCHED_NAME, REQUESTS_RECOVERY);
CREATE INDEX IDX_QRTZ_J_GRP ON QRTZ_JOB_DETAILS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_J ON QRTZ_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_JG ON QRTZ_TRIGGERS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_C ON QRTZ_TRIGGERS (SCHED_NAME, CALENDAR_NAME);
CREATE INDEX IDX_QRTZ_T_G ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP);
CREATE INDEX IDX_QRTZ_T_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_N_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_N_G_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME ON QRTZ_TRIGGERS (SCHED_NAME, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_ST ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME);
CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY);
CREATE INDEX IDX_QRTZ_FT_J_G ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_FT_JG ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_FT_T_G ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP);
CREATE INDEX IDX_QRTZ_FT_TG ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_GROUP);
@@ -0,0 +1,29 @@
-- V013: AI 테이블 추가 성능 인덱스
-- 복합 인덱스 및 자주 조회되는 컬럼에 대한 보조 인덱스
-- ai_agents: agent_id 문자열 조회 (ApiKey 검증 시)
CREATE INDEX idx_ai_agents_agent_id ON ai_agents (agent_id);
-- ai_agent_groups: group_id 문자열 조회
CREATE INDEX idx_ai_groups_group_id ON ai_agent_groups (group_id);
-- ai_agent_group_members: agent_id 역방향 조회 (에이전트 삭제 전 그룹 확인)
CREATE INDEX idx_group_members_agent ON ai_agent_group_members (agent_id);
-- ai_agent_conversations: conversation_id 문자열 조회
CREATE INDEX idx_conv_conv_id ON ai_agent_conversations (conversation_id);
-- ai_agent_conversations: company_code 조회 (멀티테넌시)
CREATE INDEX idx_conv_company ON ai_agent_conversations (user_id, updated_at DESC);
-- ai_analysis_logs: schedule_id 조회 (스케줄 실행 이력)
CREATE INDEX idx_analysis_schedule ON ai_analysis_logs (schedule_id, created_at DESC);
-- ai_analysis_logs: agent_id 조회
CREATE INDEX idx_analysis_agent ON ai_analysis_logs (agent_id, created_at DESC);
-- ai_agent_usage_logs: user_id + 기간 조회
CREATE INDEX idx_usage_user ON ai_agent_usage_logs (user_id, created_at DESC);
-- ai_knowledge_files: company_code + category 복합 조회
CREATE INDEX idx_knowledge_company_category ON ai_knowledge_files (company_code, category);
@@ -0,0 +1,200 @@
-- V014: AI 어시스턴트 메뉴 등록
-- 관리자 메뉴(MENU_TYPE='0')에 AI 어시스턴트 그룹 및 하위 7개 메뉴를 등록합니다.
-- SCREEN_GROUPS에 메뉴 그룹도 함께 등록하여 화면 그룹 트리와 연결합니다.
-- 멱등성: INSERT ... WHERE NOT EXISTS 사용 (unique constraint 없는 기존 테이블 대응).
-- ══════════════════════════════════════════════════════════════
-- 1. SCREEN_GROUPS: AI 어시스턴트 루트 그룹
-- ══════════════════════════════════════════════════════════════
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'AI 어시스턴트', 'AI_ASSISTANT', NULL, 0,
9900, '*', 'system', 'AI_ASSISTANT', 'AI 멀티 에이전트 관리 메뉴 그룹', 'Y', 'robot'
WHERE NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS
WHERE GROUP_CODE = 'AI_ASSISTANT' AND COMPANY_CODE = '*'
);
-- ══════════════════════════════════════════════════════════════
-- 2. MENU_INFO: AI 어시스턴트 부모 메뉴 (관리자 메뉴, 루트)
-- ══════════════════════════════════════════════════════════════
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT
'AI_ASSISTANT_ROOT', '0', '0', 'AI 어시스턴트',
'/admin/aiAssistant', 'AI 멀티 에이전트 관리',
9900, 'system', NOW(), 'active', '*', 'robot'
WHERE NOT EXISTS (
SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_ASSISTANT_ROOT'
);
-- ══════════════════════════════════════════════════════════════
-- 3. SCREEN_GROUPS: 하위 그룹 6개
-- PARENT_GROUP_ID는 위에서 삽입한 AI_ASSISTANT 그룹을 서브쿼리로 참조
-- ══════════════════════════════════════════════════════════════
-- 3-1. 에이전트 관리
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'에이전트 관리', 'AI_AGENTS', SG.ID, 1,
9901, '*', 'system', 'AI_ASSISTANT/AI_AGENTS', 'LLM 에이전트 CRUD', 'Y', 'cpu'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_AGENTS' AND COMPANY_CODE = '*'
);
-- 3-2. LLM 프로바이더
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'LLM 프로바이더', 'AI_PROVIDERS', SG.ID, 1,
9902, '*', 'system', 'AI_ASSISTANT/AI_PROVIDERS',
'Anthropic/OpenAI/Google/Ollama 프로바이더 관리', 'Y', 'cloud'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_PROVIDERS' AND COMPANY_CODE = '*'
);
-- 3-3. 멀티에이전트 워크스페이스
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'멀티에이전트 워크스페이스', 'AI_WORKSPACE', SG.ID, 1,
9903, '*', 'system', 'AI_ASSISTANT/AI_WORKSPACE', '에이전트 그룹 조립 및 실행', 'Y', 'diagram-3'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_WORKSPACE' AND COMPANY_CODE = '*'
);
-- 3-4. 대화 모니터링
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'대화 모니터링', 'AI_CONVERSATIONS', SG.ID, 1,
9904, '*', 'system', 'AI_ASSISTANT/AI_CONVERSATIONS', '에이전트 대화 메시지 열람', 'Y', 'chat-dots'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_CONVERSATIONS' AND COMPANY_CODE = '*'
);
-- 3-5. API 키 관리
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'API 키 관리', 'AI_API_KEYS', SG.ID, 1,
9905, '*', 'system', 'AI_ASSISTANT/AI_API_KEYS', 'sk-pipe-* API 키 발급 및 폐기', 'Y', 'key'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_API_KEYS' AND COMPANY_CODE = '*'
);
-- 3-6. 지식 라이브러리
INSERT INTO SCREEN_GROUPS (
GROUP_NAME, GROUP_CODE, PARENT_GROUP_ID, GROUP_LEVEL,
DISPLAY_ORDER, COMPANY_CODE, WRITER, HIERARCHY_PATH, DESCRIPTION, IS_ACTIVE, ICON
)
SELECT
'지식 라이브러리', 'AI_KNOWLEDGE', SG.ID, 1,
9906, '*', 'system', 'AI_ASSISTANT/AI_KNOWLEDGE', '지식 파일 업로드 및 관리', 'Y', 'book'
FROM SCREEN_GROUPS SG
WHERE SG.GROUP_CODE = 'AI_ASSISTANT' AND SG.COMPANY_CODE = '*'
AND NOT EXISTS (
SELECT 1 FROM SCREEN_GROUPS WHERE GROUP_CODE = 'AI_KNOWLEDGE' AND COMPANY_CODE = '*'
);
-- ══════════════════════════════════════════════════════════════
-- 4. MENU_INFO: 하위 메뉴 7개 (MENU_TYPE='0', 관리자 메뉴)
-- ══════════════════════════════════════════════════════════════
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_ASSISTANT', '0', 'AI_ASSISTANT_ROOT', 'AI 어시스턴트',
'/admin/aiAssistant', 'AI 어시스턴트 대시보드 (워크스페이스 리다이렉트)',
9901, 'system', NOW(), 'active', '*', 'robot'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_ASSISTANT');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_AGENTS', '0', 'AI_ASSISTANT_ROOT', '에이전트 관리',
'/admin/aiAssistant/agents', 'LLM 에이전트 CRUD',
9902, 'system', NOW(), 'active', '*', 'cpu'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_AGENTS');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_PROVIDERS', '0', 'AI_ASSISTANT_ROOT', 'LLM 프로바이더',
'/admin/aiAssistant/providers', 'Anthropic/OpenAI/Google/Ollama 프로바이더 설정',
9903, 'system', NOW(), 'active', '*', 'cloud'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_PROVIDERS');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_WORKSPACE', '0', 'AI_ASSISTANT_ROOT', '멀티에이전트 워크스페이스',
'/admin/aiAssistant/workspace', '에이전트 그룹 조립 및 실행',
9904, 'system', NOW(), 'active', '*', 'diagram-3'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_WORKSPACE');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_CONVERSATIONS', '0', 'AI_ASSISTANT_ROOT', '대화 모니터링',
'/admin/aiAssistant/conversations', '에이전트 대화 메시지 열람',
9905, 'system', NOW(), 'active', '*', 'chat-dots'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_CONVERSATIONS');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_API_KEYS', '0', 'AI_ASSISTANT_ROOT', 'API 키 관리',
'/admin/aiAssistant/api-keys-manage', 'sk-pipe-* API 키 발급 및 폐기',
9906, 'system', NOW(), 'active', '*', 'key'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_API_KEYS');
INSERT INTO MENU_INFO (
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR,
MENU_URL, MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, MENU_ICON
)
SELECT 'AI_MENU_KNOWLEDGE', '0', 'AI_ASSISTANT_ROOT', '지식 라이브러리',
'/admin/aiAssistant/knowledge', '지식 파일 업로드 및 관리',
9907, 'system', NOW(), 'active', '*', 'book'
WHERE NOT EXISTS (SELECT 1 FROM MENU_INFO WHERE OBJID = 'AI_MENU_KNOWLEDGE');
-- ══════════════════════════════════════════════════════════════
-- 5. SCREEN_GROUPS.MENU_OBJID 연결 (루트 그룹 → 루트 메뉴)
-- ══════════════════════════════════════════════════════════════
UPDATE SCREEN_GROUPS
SET MENU_OBJID = 'AI_ASSISTANT_ROOT'
WHERE GROUP_CODE = 'AI_ASSISTANT'
AND COMPANY_CODE = '*'
AND MENU_OBJID IS NULL;
@@ -0,0 +1,41 @@
-- V015: AI 메뉴 슈퍼관리자 권한 그룹 자동 매핑
-- AUTHORITY_MASTER 에서 COMPANY_CODE='*' & AUTH_CODE='SUPER_ADMIN' 인 권한 그룹을 찾아
-- V014 에서 등록된 AI 메뉴 8개(루트 + 하위 7개)를 AUTHORITY_SUB_MENU 에 삽입합니다.
-- 멱등성: ON CONFLICT (MASTER_OBJID, MENU_OBJID) DO NOTHING
INSERT INTO AUTHORITY_SUB_MENU (
OBJID
, MASTER_OBJID
, MENU_OBJID
, CREATE_YN
, READ_YN
, UPDATE_YN
, DELETE_YN
, WRITER
, CREATED_DATE
)
SELECT
AM.OBJID || '_' || MI.OBJID
, AM.OBJID
, MI.OBJID
, 'Y'
, 'Y'
, 'Y'
, 'Y'
, 'system'
, NOW()
FROM AUTHORITY_MASTER AM
CROSS JOIN MENU_INFO MI
WHERE AM.AUTH_CODE = 'SUPER_ADMIN'
AND AM.COMPANY_CODE = '*'
AND MI.OBJID IN (
'AI_ASSISTANT_ROOT'
, 'AI_MENU_ASSISTANT'
, 'AI_MENU_AGENTS'
, 'AI_MENU_PROVIDERS'
, 'AI_MENU_WORKSPACE'
, 'AI_MENU_CONVERSATIONS'
, 'AI_MENU_API_KEYS'
, 'AI_MENU_KNOWLEDGE'
)
ON CONFLICT (MASTER_OBJID, MENU_OBJID) DO NOTHING;
@@ -0,0 +1,8 @@
-- V014 가 등록한 자식 메뉴 'AI_MENU_ASSISTANT' 는 부모 'AI_ASSISTANT_ROOT' 와
-- URL(/admin/aiAssistant) 이 같아 사이드바에 'AI 어시스턴트' 가 두 번 표시된다.
-- 자식 항목을 soft-delete 하여 중복 표시 제거.
-- (V014 자체는 Flyway 체크섬 보호 때문에 수정하지 않고 후행 마이그레이션으로 정리)
UPDATE menu_info
SET status = 'deleted'
WHERE objid = 'AI_MENU_ASSISTANT'
AND status <> 'deleted';
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentApiKeyMapper">
<resultMap id="ApiKeyMap" type="com.erp.ai.model.AiAgentApiKey">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="key_hash" column="key_hash"/>
<result property="key_prefix" column="key_prefix"/>
<result property="user_id" column="user_id"/>
<result property="company_code" column="company_code"/>
<result property="agent_id" column="agent_id"/>
<result property="permissions" column="permissions"/>
<result property="rate_limit" column="rate_limit"/>
<result property="monthly_token_limit" column="monthly_token_limit"/>
<result property="status" column="status"/>
<result property="last_used_at" column="last_used_at"/>
<result property="usage_count" column="usage_count"/>
<result property="total_tokens" column="total_tokens"/>
<result property="expires_at" column="expires_at"/>
<result property="created_at" column="created_at"/>
</resultMap>
<sql id="ApiKeyColumns">
id, name, key_hash, key_prefix, user_id, company_code, agent_id,
permissions::text AS permissions, rate_limit, monthly_token_limit,
status, last_used_at, usage_count, total_tokens, expires_at, created_at
</sql>
<select id="listAll" resultMap="ApiKeyMap">
SELECT <include refid="ApiKeyColumns"/>
FROM ai_agent_api_keys ORDER BY created_at DESC
</select>
<select id="listByUser" resultMap="ApiKeyMap">
SELECT <include refid="ApiKeyColumns"/>
FROM ai_agent_api_keys WHERE user_id = #{userId} ORDER BY created_at DESC
</select>
<select id="getById" resultMap="ApiKeyMap">
SELECT <include refid="ApiKeyColumns"/>
FROM ai_agent_api_keys WHERE id = #{id}
</select>
<select id="getByKeyHash" resultMap="ApiKeyMap">
SELECT <include refid="ApiKeyColumns"/>
FROM ai_agent_api_keys
WHERE key_hash = #{keyHash} AND status = 'active'
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentApiKey" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_api_keys
(name, key_hash, key_prefix, user_id, company_code, agent_id, permissions, rate_limit, monthly_token_limit, expires_at)
VALUES (#{name}, #{key_hash}, #{key_prefix}, #{user_id}, #{company_code}, #{agent_id},
COALESCE(#{permissions}::jsonb, '["chat"]'::jsonb),
COALESCE(#{rate_limit}, 60),
COALESCE(#{monthly_token_limit}, 1000000),
#{expires_at})
</insert>
<delete id="delete">
DELETE FROM ai_agent_api_keys
WHERE id = #{id} AND (user_id = #{userId} OR #{userId} = 'wace')
</delete>
<update id="updateLastUsed">
UPDATE ai_agent_api_keys
SET last_used_at = NOW(), usage_count = usage_count + 1
WHERE id = #{id}
</update>
<update id="addTokenUsage">
UPDATE ai_agent_api_keys
SET total_tokens = total_tokens + #{tokens}
WHERE id = #{id}
</update>
<select id="countActive" resultType="int">
SELECT COUNT(*) FROM ai_agent_api_keys WHERE status = 'active'
</select>
</mapper>
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentConversationMapper">
<resultMap id="ConvMap" type="com.erp.ai.model.AiAgentConversation">
<id property="id" column="id"/>
<result property="conversation_id" column="conversation_id"/>
<result property="agent_id" column="agent_id"/>
<result property="user_id" column="user_id"/>
<result property="api_key_id" column="api_key_id"/>
<result property="title" column="title"/>
<result property="message_count" column="message_count"/>
<result property="total_tokens" column="total_tokens"/>
<result property="status" column="status"/>
<result property="metadata" column="metadata"/>
<result property="created_at" column="created_at"/>
<result property="updated_at" column="updated_at"/>
<result property="agent_name" column="agent_name"/>
<result property="display_name" column="display_name"/>
</resultMap>
<select id="list" resultMap="ConvMap">
SELECT c.id, c.conversation_id, c.agent_id, c.user_id, c.api_key_id, c.title,
c.message_count, c.total_tokens, c.status, c.metadata::text AS metadata,
c.created_at, c.updated_at,
a.name AS agent_name,
COALESCE(a.name, c.metadata->>'group_name') AS display_name
FROM ai_agent_conversations c
LEFT JOIN ai_agents a ON c.agent_id = a.id
<where>
<if test="agentId != null">c.agent_id = #{agentId}</if>
</where>
ORDER BY c.updated_at DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="count" resultType="int">
SELECT COUNT(*) FROM ai_agent_conversations
<where>
<if test="agentId != null">agent_id = #{agentId}</if>
</where>
</select>
<select id="getById" resultMap="ConvMap">
SELECT c.id, c.conversation_id, c.agent_id, c.user_id, c.api_key_id, c.title,
c.message_count, c.total_tokens, c.status, c.metadata::text AS metadata,
c.created_at, c.updated_at, a.name AS agent_name
FROM ai_agent_conversations c
LEFT JOIN ai_agents a ON c.agent_id = a.id
WHERE c.id = #{id}
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentConversation" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_conversations (conversation_id, agent_id, user_id, api_key_id, title, metadata)
VALUES (#{conversation_id}, #{agent_id}, #{user_id}, #{api_key_id}, #{title},
COALESCE(#{metadata}::jsonb, '{}'::jsonb))
</insert>
<update id="updateMeta">
UPDATE ai_agent_conversations
SET title = #{title}, metadata = #{metadata}::jsonb, updated_at = NOW()
WHERE id = #{id}
</update>
<update id="incrementStats">
UPDATE ai_agent_conversations
SET message_count = message_count + 1, total_tokens = total_tokens + #{tokens}, updated_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM ai_agent_conversations WHERE id = #{id}
</delete>
</mapper>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentGroupMapper">
<resultMap id="AiAgentGroupMap" type="com.erp.ai.model.AiAgentGroup">
<id property="id" column="id"/>
<result property="group_id" column="group_id"/>
<result property="name" column="name"/>
<result property="description" column="description"/>
<result property="execution_mode" column="execution_mode"/>
<result property="status" column="status"/>
<result property="company_code" column="company_code"/>
<result property="created_by" column="created_by"/>
<result property="created_at" column="created_at"/>
<result property="updated_at" column="updated_at"/>
<result property="member_count" column="member_count"/>
</resultMap>
<select id="list" resultMap="AiAgentGroupMap">
SELECT g.id, g.group_id, g.name, g.description, g.execution_mode, g.status,
g.company_code, g.created_by, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) AS member_count
FROM ai_agent_groups g
WHERE g.status != 'archived'
<if test="companyCode != null">
AND (g.company_code = #{companyCode} OR g.company_code IS NULL OR g.company_code = '*')
</if>
ORDER BY g.created_at DESC
</select>
<select id="getById" resultMap="AiAgentGroupMap">
SELECT id, group_id, name, description, execution_mode, status,
company_code, created_by, created_at, updated_at
FROM ai_agent_groups WHERE id = #{id}
</select>
<select id="getByGroupId" resultMap="AiAgentGroupMap">
SELECT id, group_id, name, description, execution_mode, status,
company_code, created_by, created_at, updated_at
FROM ai_agent_groups WHERE group_id = #{groupId} AND status = 'active'
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentGroup" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_groups (group_id, name, description, execution_mode, status, company_code, created_by)
VALUES (#{group_id}, #{name}, #{description},
COALESCE(#{execution_mode}, 'mixed'),
COALESCE(#{status}, 'active'),
#{company_code}, #{created_by})
</insert>
<update id="update" parameterType="com.erp.ai.model.AiAgentGroup">
UPDATE ai_agent_groups
<set>
<if test="name != null">name = #{name},</if>
<if test="description != null">description = #{description},</if>
<if test="execution_mode != null">execution_mode = #{execution_mode},</if>
<if test="status != null">status = #{status},</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
<update id="softDelete">
UPDATE ai_agent_groups SET status = 'archived', updated_at = NOW() WHERE id = #{id}
</update>
<update id="touchUpdatedAt">
UPDATE ai_agent_groups SET updated_at = NOW() WHERE id = #{id}
</update>
</mapper>
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentGroupMemberMapper">
<resultMap id="MemberMap" type="com.erp.ai.model.AiAgentGroupMember">
<id property="id" column="id"/>
<result property="group_id" column="group_id"/>
<result property="agent_id" column="agent_id"/>
<result property="role_name" column="role_name"/>
<result property="connectors" column="connectors"/>
<result property="execution_order" column="execution_order"/>
<result property="config" column="config"/>
<result property="created_at" column="created_at"/>
<result property="updated_at" column="updated_at"/>
<result property="agent_name" column="agent_name"/>
<result property="agent_model" column="agent_model"/>
</resultMap>
<select id="listByGroupId" resultMap="MemberMap">
SELECT m.id, m.group_id, m.agent_id, m.role_name,
m.connectors::text AS connectors, m.execution_order,
m.config::text AS config, m.created_at, m.updated_at,
a.name AS agent_name, a.model AS agent_model
FROM ai_agent_group_members m
LEFT JOIN ai_agents a ON m.agent_id = a.id
WHERE m.group_id = #{groupId}
ORDER BY m.execution_order
</select>
<select id="getById" resultMap="MemberMap">
SELECT m.id, m.group_id, m.agent_id, m.role_name,
m.connectors::text AS connectors, m.execution_order,
m.config::text AS config, m.created_at, m.updated_at
FROM ai_agent_group_members m
WHERE m.id = #{id}
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentGroupMember" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_group_members (group_id, agent_id, role_name, connectors, execution_order, config)
VALUES (#{group_id}, #{agent_id}, #{role_name},
COALESCE(#{connectors}::jsonb, '[]'::jsonb),
COALESCE(#{execution_order}, 1),
COALESCE(#{config}::jsonb, '{}'::jsonb))
</insert>
<update id="update" parameterType="com.erp.ai.model.AiAgentGroupMember">
UPDATE ai_agent_group_members
<set>
<if test="role_name != null">role_name = #{role_name},</if>
<if test="connectors != null">connectors = #{connectors}::jsonb,</if>
<if test="execution_order != null">execution_order = #{execution_order},</if>
<if test="config != null">config = #{config}::jsonb,</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM ai_agent_group_members WHERE id = #{id}
</delete>
<select id="countByGroupId" resultType="int">
SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = #{groupId}
</select>
</mapper>
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentMapper">
<resultMap id="AiAgentMap" type="com.erp.ai.model.AiAgent">
<id property="id" column="id"/>
<result property="agent_id" column="agent_id"/>
<result property="name" column="name"/>
<result property="description" column="description"/>
<result property="model" column="model"/>
<result property="system_prompt" column="system_prompt"/>
<result property="tools" column="tools"/>
<result property="config" column="config"/>
<result property="status" column="status"/>
<result property="company_code" column="company_code"/>
<result property="created_by" column="created_by"/>
<result property="created_at" column="created_at"/>
<result property="updated_at" column="updated_at"/>
</resultMap>
<select id="list" parameterType="map" resultMap="AiAgentMap">
SELECT id, agent_id, name, description, model, system_prompt,
tools::text AS tools, config::text AS config, status, company_code, created_by, created_at, updated_at
FROM ai_agents
WHERE 1=1
<choose>
<when test="status != null">
AND status = #{status}
</when>
<otherwise>
AND status != 'archived'
</otherwise>
</choose>
<if test="company_code != null">
AND (company_code = #{company_code} OR company_code IS NULL OR company_code = '*')
</if>
<if test="search != null">
AND (name ILIKE #{search} OR agent_id ILIKE #{search} OR description ILIKE #{search})
</if>
ORDER BY created_at DESC
</select>
<select id="getById" resultMap="AiAgentMap">
SELECT id, agent_id, name, description, model, system_prompt,
tools::text AS tools, config::text AS config, status, company_code, created_by, created_at, updated_at
FROM ai_agents
WHERE id = #{id}
</select>
<select id="getByAgentId" resultMap="AiAgentMap">
SELECT id, agent_id, name, description, model, system_prompt,
tools::text AS tools, config::text AS config, status, company_code, created_by, created_at, updated_at
FROM ai_agents
WHERE agent_id = #{agentId}
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgent" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agents (agent_id, name, description, model, system_prompt, tools, config, status, company_code, created_by)
VALUES (#{agent_id}, #{name}, #{description},
COALESCE(#{model}, 'claude-sonnet-4-20250514'),
#{system_prompt},
COALESCE(#{tools}::jsonb, '[]'::jsonb),
COALESCE(#{config}::jsonb, '{}'::jsonb),
COALESCE(#{status}, 'active'),
#{company_code}, #{created_by})
</insert>
<update id="update">
UPDATE ai_agents
<set>
<if test="fields.name != null">name = #{fields.name},</if>
<if test="fields.description != null">description = #{fields.description},</if>
<if test="fields.model != null">model = #{fields.model},</if>
<if test="fields.system_prompt != null">system_prompt = #{fields.system_prompt},</if>
<if test="fields.tools != null">tools = #{fields.tools}::jsonb,</if>
<if test="fields.config != null">config = #{fields.config}::jsonb,</if>
<if test="fields.status != null">status = #{fields.status},</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
<update id="softDelete">
UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = #{id}
</update>
<select id="countActive" resultType="int">
SELECT COUNT(*) FROM ai_agents WHERE status = 'active'
</select>
<select id="getActiveAgents" resultMap="AiAgentMap">
SELECT id, agent_id, name, description, model, system_prompt,
tools::text AS tools, config::text AS config, status, company_code, created_by, created_at, updated_at
FROM ai_agents WHERE status = 'active' ORDER BY name
</select>
</mapper>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentMessageMapper">
<resultMap id="MessageMap" type="com.erp.ai.model.AiAgentMessage">
<id property="id" column="id"/>
<result property="conversation_id" column="conversation_id"/>
<result property="role" column="role"/>
<result property="content" column="content"/>
<result property="tool_calls" column="tool_calls"/>
<result property="token_count" column="token_count"/>
<result property="metadata" column="metadata"/>
<result property="created_at" column="created_at"/>
</resultMap>
<select id="listByConversationId" resultMap="MessageMap">
SELECT id, conversation_id, role, content,
tool_calls::text AS tool_calls, token_count,
metadata::text AS metadata, created_at
FROM ai_agent_messages
WHERE conversation_id = #{conversationId}
ORDER BY created_at
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentMessage" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_messages (conversation_id, role, content, tool_calls, token_count, metadata)
VALUES (#{conversation_id}, #{role}, #{content},
<choose>
<when test="tool_calls != null">#{tool_calls}::jsonb</when>
<otherwise>NULL</otherwise>
</choose>,
COALESCE(#{token_count}, 0),
COALESCE(#{metadata}::jsonb, '{}'::jsonb))
</insert>
<delete id="deleteByConversationId">
DELETE FROM ai_agent_messages WHERE conversation_id = #{conversationId}
</delete>
</mapper>
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.erp.ai.mapper.AiAgentScheduleMapper">
<resultMap id="ScheduleMap" type="com.erp.ai.model.AiAgentSchedule">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="group_id" column="group_id"/>
<result property="cron_expression" column="cron_expression"/>
<result property="timezone" column="timezone"/>
<result property="input_message" column="input_message"/>
<result property="notification" column="notification"/>
<result property="is_active" column="is_active"/>
<result property="last_run_at" column="last_run_at"/>
<result property="run_count" column="run_count"/>
<result property="company_code" column="company_code"/>
<result property="created_by" column="created_by"/>
<result property="created_at" column="created_at"/>
<result property="updated_at" column="updated_at"/>
<result property="group_name" column="group_name"/>
</resultMap>
<sql id="ScheduleColumns">
s.id, s.name, s.group_id, s.cron_expression, s.timezone, s.input_message,
s.notification::text AS notification, s.is_active, s.last_run_at, s.run_count,
s.company_code, s.created_by, s.created_at, s.updated_at,
g.name AS group_name
</sql>
<select id="listAll" resultMap="ScheduleMap">
SELECT <include refid="ScheduleColumns"/>
FROM ai_agent_schedules s
LEFT JOIN ai_agent_groups g ON s.group_id = g.id
ORDER BY s.created_at DESC
</select>
<select id="listActive" resultMap="ScheduleMap">
SELECT <include refid="ScheduleColumns"/>
FROM ai_agent_schedules s
LEFT JOIN ai_agent_groups g ON s.group_id = g.id
WHERE s.is_active = TRUE
</select>
<select id="getById" resultMap="ScheduleMap">
SELECT <include refid="ScheduleColumns"/>
FROM ai_agent_schedules s
LEFT JOIN ai_agent_groups g ON s.group_id = g.id
WHERE s.id = #{id}
</select>
<insert id="insert" parameterType="com.erp.ai.model.AiAgentSchedule" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ai_agent_schedules
(name, group_id, cron_expression, timezone, input_message, notification, is_active, company_code, created_by)
VALUES (#{name}, #{group_id}, #{cron_expression},
COALESCE(#{timezone}, 'Asia/Seoul'),
#{input_message},
COALESCE(#{notification}::jsonb, '{}'::jsonb),
COALESCE(#{is_active}, TRUE),
#{company_code}, #{created_by})
</insert>
<update id="update" parameterType="com.erp.ai.model.AiAgentSchedule">
UPDATE ai_agent_schedules
<set>
<if test="name != null">name = #{name},</if>
<if test="cron_expression != null">cron_expression = #{cron_expression},</if>
<if test="timezone != null">timezone = #{timezone},</if>
<if test="input_message != null">input_message = #{input_message},</if>
<if test="notification != null">notification = #{notification}::jsonb,</if>
<if test="is_active != null">is_active = #{is_active},</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM ai_agent_schedules WHERE id = #{id}
</delete>
<update id="markRun">
UPDATE ai_agent_schedules
SET last_run_at = NOW(), run_count = run_count + 1
WHERE id = #{id}
</update>
</mapper>

Some files were not shown because too many files have changed in this diff Show More