merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)
충돌 해결 5개 파일: - .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존 - docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합 - frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인 - frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리 - frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴) 검증: - backend: ./gradlew compileJava 성공 (Java 21) - frontend: 머지된 4개 파일 관련 타입 에러 0개 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
@@ -34,6 +35,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.erp.alarm;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SCADA 데모(또는 실제 PLC 게이트웨이) 가 호출해서 작업자 폰으로 알람을 push 하는 엔드포인트.
|
||||
* 인증은 SecurityConfig 단계에선 permitAll 이지만, 시연 한정이므로 미인증 호출 허용.
|
||||
* 향후 운영 단계로 가면 ROLE_SYSTEM 같은 별도 가드 필요.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/demo/alarm")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AlarmController {
|
||||
|
||||
private final AlarmWebSocketHandler alarmHandler;
|
||||
|
||||
/**
|
||||
* POST /api/demo/alarm/trigger
|
||||
*
|
||||
* body: {
|
||||
* "target_user_id": "ky",
|
||||
* "alarm": {
|
||||
* "code": "P-IN-HH",
|
||||
* "severity": "CRITICAL",
|
||||
* "title": "BW-A1 펌프 과압 / 누설 의심",
|
||||
* "message": "...",
|
||||
* "location": "펌프룸 A · BW-A1",
|
||||
* "comp": "bw-a1"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/trigger")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> trigger(@RequestBody Map<String, Object> body) {
|
||||
String targetUserId = (String) body.get("target_user_id");
|
||||
if (!StringUtils.hasText(targetUserId)) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error("target_user_id 가 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> alarm = (Map<String, Object>) body.getOrDefault("alarm", new HashMap<>());
|
||||
alarm.putIfAbsent("ts", System.currentTimeMillis());
|
||||
|
||||
int delivered = alarmHandler.sendAlarm(targetUserId, alarm);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("target_user_id", targetUserId);
|
||||
result.put("delivered_to", delivered);
|
||||
result.put("active_users", alarmHandler.activeUsers());
|
||||
|
||||
log.info("[Alarm] trigger user={}, delivered={}", targetUserId, delivered);
|
||||
String msg = delivered > 0 ? "알람 전송됨 (" + delivered + "건)" : "수신자 미접속 — 알람 큐 없음";
|
||||
return ResponseEntity.ok(ApiResponse.success(result, msg));
|
||||
}
|
||||
|
||||
/** GET /api/demo/alarm/status — 현재 ws 접속 사용자 목록 (디버그/시연 안전망) */
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> status() {
|
||||
Map<String, Object> result = Map.of(
|
||||
"active_users", alarmHandler.activeUsers(),
|
||||
"active_count", alarmHandler.activeUsers().size()
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "ok"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.erp.alarm;
|
||||
|
||||
import com.erp.security.JwtTokenProvider;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AlarmHandshakeInterceptor implements HandshakeInterceptor {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Map<String, Object> attributes) {
|
||||
String token = extractToken(request);
|
||||
if (token == null || !jwtTokenProvider.validateToken(token)) {
|
||||
log.warn("[WS] handshake rejected: invalid or missing token (uri={})", request.getURI());
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtTokenProvider.getClaims(token);
|
||||
String userId = claims.get("user_id", String.class);
|
||||
String companyCode = claims.get("company_code", String.class);
|
||||
if (userId == null) {
|
||||
log.warn("[WS] handshake rejected: token has no user_id");
|
||||
return false;
|
||||
}
|
||||
attributes.put("user_id", userId);
|
||||
attributes.put("company_code", companyCode);
|
||||
log.info("[WS] handshake ok: user={}, company={}", userId, companyCode);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("[WS] handshake rejected: claims parse failed - {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Exception exception) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private String extractToken(ServerHttpRequest request) {
|
||||
// 브라우저 WebSocket API 는 커스텀 헤더를 못 붙이므로 query string ?token= 우선.
|
||||
// 비-브라우저 클라이언트(서버↔서버) 를 위해 Authorization: Bearer 도 fallback 으로 허용.
|
||||
if (request instanceof ServletServerHttpRequest sreq) {
|
||||
String q = sreq.getServletRequest().getParameter("token");
|
||||
if (q != null && !q.isBlank()) return q;
|
||||
}
|
||||
String auth = request.getHeaders().getFirst("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
return auth.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.erp.alarm;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AlarmWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// userId -> set of sessions (한 사용자가 여러 디바이스/탭 동시 접속 가능)
|
||||
private final Map<String, Set<WebSocketSession>> sessionsByUser = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
String userId = (String) session.getAttributes().get("user_id");
|
||||
if (userId == null) {
|
||||
session.close(CloseStatus.POLICY_VIOLATION);
|
||||
return;
|
||||
}
|
||||
sessionsByUser.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session);
|
||||
log.info("[WS] connected: user={}, sessions={}", userId, sessionsByUser.get(userId).size());
|
||||
|
||||
Map<String, Object> hello = Map.of("type", "hello", "user_id", userId, "ts", System.currentTimeMillis());
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(hello)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
String userId = (String) session.getAttributes().get("user_id");
|
||||
if (userId == null) return;
|
||||
Set<WebSocketSession> set = sessionsByUser.get(userId);
|
||||
if (set != null) {
|
||||
set.remove(session);
|
||||
if (set.isEmpty()) sessionsByUser.remove(userId);
|
||||
}
|
||||
log.info("[WS] closed: user={}, status={}", userId, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
// 폰 → 서버 메시지 (예: ack). 시연 단계에선 ack 만 받아 로그.
|
||||
String userId = (String) session.getAttributes().get("user_id");
|
||||
log.info("[WS] msg from {}: {}", userId, message.getPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 user_id 의 모든 active session 에 알람 push.
|
||||
* 반환값 = 실제로 메시지가 송신된 세션 수 (0 이면 미접속).
|
||||
*/
|
||||
public int sendAlarm(String userId, Map<String, Object> alarm) {
|
||||
Set<WebSocketSession> set = sessionsByUser.get(userId);
|
||||
if (set == null || set.isEmpty()) {
|
||||
log.info("[WS] no active session for user={}, skipped", userId);
|
||||
return 0;
|
||||
}
|
||||
Map<String, Object> envelope = Map.of("type", "alarm", "payload", alarm);
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(envelope);
|
||||
} catch (Exception e) {
|
||||
log.error("[WS] serialize fail", e);
|
||||
return 0;
|
||||
}
|
||||
int sent = 0;
|
||||
for (WebSocketSession s : set) {
|
||||
try {
|
||||
if (s.isOpen()) {
|
||||
s.sendMessage(new TextMessage(json));
|
||||
sent++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[WS] send fail to {}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** 디버그/모니터링 용 */
|
||||
public Set<String> activeUsers() {
|
||||
return sessionsByUser.keySet();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.erp.alarm;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final AlarmWebSocketHandler alarmHandler;
|
||||
private final AlarmHandshakeInterceptor handshakeInterceptor;
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
// path 를 /api/demo/* 안에 두는 이유:
|
||||
// 운영 Traefik 라우트가 PathPrefix(`/api`) 만 backend 로 보내므로 별도 라우트 추가가 필요 없음.
|
||||
// /demo/* 는 시연용임을 path 에서 명시 — 정식 알람 시스템(/api/alarm/*)과 분리.
|
||||
registry.addHandler(alarmHandler, "/api/demo/ws/alarm")
|
||||
.addInterceptors(handshakeInterceptor)
|
||||
.setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
+16
@@ -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);
|
||||
+22
@@ -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;
|
||||
+41
@@ -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;
|
||||
+8
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user