AI관리 시스템 교체: ai-assistant 제거 + 멀티 에이전트 오케스트레이션 이식
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s

Epic A: ai-assistant 디렉토리/Spring 프록시/프론트엔드 메뉴 완전 제거
Epic B: Flyway 도입 + 13 신규 테이블 마이그레이션
Epic C: 9 서비스 + 7 컨트롤러 + LlmClient 추상화 (Java 21/Spring/MyBatis)
Epic D: ApiKey 인증 필터 (sk-pipe-* 키 SHA-256 검증)
Epic E: OpenClaw 외부 엔진 docker-compose 통합
Epic F: Next.js 7 페이지 + lib/api/aiAgent.ts 이식
Epic G: 화면 그룹/메뉴 등록 마이그레이션 (V014)
Epic H: 통합 빌드 검증

- DB: invyone PostgreSQL에 ai_agents/ai_agent_groups/... 13 테이블 + Quartz
- 멀티테넌시: 모든 테이블에 company_code 강제 필터
- LLM: Anthropic/OpenAI/Google/Ollama 직접 클라이언트 (Spring AI 미도입)
- 스케줄러: Quartz JDBC JobStore (cron 기반)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-04-27 22:49:43 +09:00
parent 5af633d251
commit 229b09b895
141 changed files with 7508 additions and 9352 deletions
+81
View File
@@ -0,0 +1,81 @@
import { apiClient } from "./client";
const BASE = "/ai-agents";
export const aiAgentApi = {
// 에이전트 CRUD
list: (params?: { status?: string; search?: string }) =>
apiClient.get(BASE, { params }).then((r) => r.data),
getById: (id: number) =>
apiClient.get(`${BASE}/${id}`).then((r) => r.data),
create: (data: any) =>
apiClient.post(BASE, data).then((r) => r.data),
update: (id: number, data: any) =>
apiClient.put(`${BASE}/${id}`, data).then((r) => r.data),
delete: (id: number) =>
apiClient.delete(`${BASE}/${id}`).then((r) => r.data),
// API 키 관리
listKeys: () =>
apiClient.get(`${BASE}/keys/list`).then((r) => r.data),
createKey: (data: { name: string; agent_id?: number; rate_limit?: number }) =>
apiClient.post(`${BASE}/keys`, data).then((r) => r.data),
revokeKey: (id: number) =>
apiClient.delete(`${BASE}/keys/${id}`).then((r) => r.data),
// LLM 프로바이더
listProviders: () =>
apiClient.get(`${BASE}/providers/list`).then((r) => r.data),
createProvider: (data: any) =>
apiClient.post(`${BASE}/providers`, data).then((r) => r.data),
updateProvider: (id: number, data: any) =>
apiClient.put(`${BASE}/providers/${id}`, data).then((r) => r.data),
deleteProvider: (id: number) =>
apiClient.delete(`${BASE}/providers/${id}`).then((r) => r.data),
// 대화 모니터링
listConversations: (params?: { page?: number; limit?: number; agent_id?: number }) =>
apiClient.get(`${BASE}/conversations/list`, { params }).then((r) => r.data),
getConversation: (id: number) =>
apiClient.get(`${BASE}/conversations/${id}`).then((r) => r.data),
// 사용량
usageSummary: () =>
apiClient.get(`${BASE}/usage/summary`).then((r) => r.data),
usageLogs: (params?: { page?: number; limit?: number }) =>
apiClient.get(`${BASE}/usage/logs`, { params }).then((r) => r.data),
usageDaily: (days?: number) =>
apiClient.get(`${BASE}/usage/daily`, { params: { days } }).then((r) => r.data),
// 멀티 에이전트 그룹
listGroups: () =>
apiClient.get("/ai-agent-groups").then((r) => r.data),
getGroup: (id: number) =>
apiClient.get(`/ai-agent-groups/${id}`).then((r) => r.data),
createGroup: (data: { name: string; description?: string }) =>
apiClient.post("/ai-agent-groups", data).then((r) => r.data),
updateGroup: (id: number, data: any) =>
apiClient.put(`/ai-agent-groups/${id}`, data).then((r) => r.data),
deleteGroup: (id: number) =>
apiClient.delete(`/ai-agent-groups/${id}`).then((r) => r.data),
addGroupMember: (groupId: number, data: any) =>
apiClient.post(`/ai-agent-groups/${groupId}/members`, data).then((r) => r.data),
updateGroupMember: (memberId: number, data: any) =>
apiClient.put(`/ai-agent-groups/members/${memberId}`, data).then((r) => r.data),
removeGroupMember: (memberId: number) =>
apiClient.delete(`/ai-agent-groups/members/${memberId}`).then((r) => r.data),
getAvailableConnectors: () =>
apiClient.get("/ai-agent-groups/connectors").then((r) => r.data),
// 지식 파일 라이브러리
listKnowledge: (params?: { category?: string; search?: string }) =>
apiClient.get("/ai-knowledge", { params }).then((r) => r.data),
getKnowledge: (id: number) =>
apiClient.get(`/ai-knowledge/${id}`).then((r) => r.data),
createKnowledge: (data: { name: string; file_name: string; category: string; description?: string; content: string }) =>
apiClient.post("/ai-knowledge", data).then((r) => r.data),
updateKnowledge: (id: number, data: any) =>
apiClient.put(`/ai-knowledge/${id}`, data).then((r) => r.data),
deleteKnowledge: (id: number) =>
apiClient.delete(`/ai-knowledge/${id}`).then((r) => r.data),
};
-127
View File
@@ -1,127 +0,0 @@
/**
* AI 어시스턴트 전용 API 클라이언트
* - INVION와 같은 서비스/같은 포트: /api/ai/v1 로 호출 (Next → backend-node → AI 서비스 프록시)
* - 인증 토큰은 sessionStorage 'ai-assistant-auth' 사용 (INVION 인증과 분리)
*/
import axios, { AxiosError } from "axios";
import type { AiAssistantAuthState } from "./types";
const STORAGE_KEY = "ai-assistant-auth";
/** 같은 오리진 기준 AI API prefix (backend-node가 /api/ai/v1 을 AI 서비스로 프록시) */
function getBaseUrl(): string {
if (typeof window === "undefined") return "";
return "/api/ai/v1";
}
export function getAiAssistantAuth(): AiAssistantAuthState | null {
if (typeof window === "undefined") return null;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as AiAssistantAuthState) : null;
} catch {
return null;
}
}
export function setAiAssistantAuth(state: AiAssistantAuthState | null): void {
if (typeof window === "undefined") return;
if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
else sessionStorage.removeItem(STORAGE_KEY);
}
export function getAiAssistantAccessToken(): string | null {
return getAiAssistantAuth()?.accessToken ?? null;
}
let refreshing = false;
const queue: Array<{ resolve: (t: string) => void; reject: (e: unknown) => void }> = [];
function processQueue(error: unknown, token: string | null) {
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
queue.length = 0;
}
const client = axios.create({
baseURL: "",
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const base = getBaseUrl();
if (base) config.baseURL = base;
const token = getAiAssistantAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
client.interceptors.response.use(
(res) => res,
async (err: AxiosError) => {
const original = err.config as typeof err.config & { _retry?: boolean };
const isAuth = original?.url?.includes("/auth/");
if (isAuth || err.response?.status !== 401 || original?._retry) {
return Promise.reject(err);
}
if (refreshing) {
return new Promise<string>((resolve, reject) => {
queue.push({ resolve, reject });
})
.then((token) => {
if (original.headers) original.headers.Authorization = `Bearer ${token}`;
return client(original);
})
.catch((e) => Promise.reject(e));
}
original._retry = true;
refreshing = true;
const auth = getAiAssistantAuth();
try {
if (!auth?.refreshToken) throw new Error("No refresh token");
const base = getBaseUrl();
const { data } = await axios.post<{ data: { accessToken: string } }>(
`${base}/auth/refresh`,
{ refreshToken: auth.refreshToken },
{ headers: { "Content-Type": "application/json" } },
);
const newToken = data?.data?.accessToken;
if (!newToken) throw new Error("No access token");
const newState: AiAssistantAuthState = {
...auth,
accessToken: newToken,
};
setAiAssistantAuth(newState);
processQueue(null, newToken);
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`;
return client(original);
} catch (e) {
processQueue(e, null);
setAiAssistantAuth(null);
return Promise.reject(err);
} finally {
refreshing = false;
}
},
);
/** AI 어시스턴트 로그인 (이메일/비밀번호) */
export async function loginAiAssistant(
email: string,
password: string,
): Promise<AiAssistantAuthState> {
const base = getBaseUrl();
const { data } = await axios.post<{
data: { user: AiAssistantAuthState["user"]; accessToken: string; refreshToken: string };
}>(`${base}/auth/login`, { email, password }, { headers: { "Content-Type": "application/json" }, withCredentials: true });
const state: AiAssistantAuthState = {
user: data.data.user,
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken,
};
setAiAssistantAuth(state);
return state;
}
export default client;
-8
View File
@@ -1,8 +0,0 @@
export { default as aiAssistantApi } from "./client";
export {
getAiAssistantAuth,
setAiAssistantAuth,
getAiAssistantAccessToken,
loginAiAssistant,
} from "./client";
export * from "./types";
-70
View File
@@ -1,70 +0,0 @@
/**
* AI 어시스턴트 API 응답/요청 타입 (workspace_assistant 백엔드 연동)
*/
export interface AiAssistantUser {
id: number;
email: string;
name: string;
role: "user" | "admin";
status?: string;
plan?: string;
monthlyTokenLimit?: number;
createdAt?: string;
}
export interface AiAssistantAuthState {
user: AiAssistantUser;
accessToken: string;
refreshToken: string;
}
export interface UsageSummary {
usage?: {
today?: { tokens?: number; requests?: number };
monthly?: { totalTokens?: number; totalCost?: number };
};
limit?: { monthly?: number };
plan?: string;
}
export interface ApiKeyItem {
id: number;
name: string;
keyPrefix: string;
status: string;
usageCount?: number;
lastUsedAt?: string | null;
createdAt: string;
}
export interface UsageLogItem {
id: number;
success: boolean;
providerName?: string;
modelName?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
costUsd?: number;
responseTimeMs?: number;
createdAt: string;
}
export interface AdminStats {
users?: { total?: number; active?: number };
apiKeys?: { total?: number };
providers?: { active?: number };
}
export interface LlmProvider {
id: number;
displayName: string;
modelName: string;
apiKey?: string;
priority: number;
maxTokens?: number;
temperature?: number;
isActive: boolean;
costPer1kInputTokens?: number;
}