diff --git a/.cursor/rules/api-client-usage.mdc b/.cursor/rules/api-client-usage.mdc index e7fc2810..7df44ca7 100644 --- a/.cursor/rules/api-client-usage.mdc +++ b/.cursor/rules/api-client-usage.mdc @@ -11,7 +11,7 @@ description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 ## 이유 -1. **환경별 URL 자동 처리**: 프로덕션(`v1.invion.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청 +1. **환경별 URL 자동 처리**: 프로덕션(`v1.invyone.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청 2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링 3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가 4. **유지보수성**: API 변경 시 한 곳에서만 수정 @@ -116,9 +116,9 @@ const getApiBaseUrl = (): string => { if (typeof window !== "undefined") { const currentHost = window.location.hostname; - // 프로덕션: v1.invion.com → api.invion.com - if (currentHost === "v1.invion.com") { - return "https://api.invion.com/api"; + // 프로덕션: v1.invyone.com → api.invyone.com + if (currentHost === "v1.invyone.com") { + return "https://api.invyone.com/api"; } // 로컬 개발 @@ -155,7 +155,7 @@ API 클라이언트는 자동으로 환경을 감지합니다: | 현재 호스트 | 백엔드 API URL | | ---------------- | ----------------------------- | -| `v1.invion.com` | `https://api.invion.com/api` | +| `v1.invyone.com` | `https://api.invyone.com/api` | | `localhost:9771` | `http://localhost:8080/api` | | `localhost:3000` | `http://localhost:8080/api` | diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 67461cff..b2525cff 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -661,7 +661,7 @@ if (req.user && req.user.companyCode !== "*") { | 환경 | 프론트엔드 | 백엔드 API | |------|-----------|-----------| -| 프로덕션 | `v1.invion.com` | `https://api.invion.com/api` | +| 프로덕션 | `v1.invyone.com` | `https://api.invyone.com/api` | | 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` | - 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별 diff --git a/.omc/plans/ai-orchestration-replacement-architecture.md b/.omc/plans/ai-orchestration-replacement-architecture.md new file mode 100644 index 00000000..f2d3de06 --- /dev/null +++ b/.omc/plans/ai-orchestration-replacement-architecture.md @@ -0,0 +1,1183 @@ +# AI 오케스트레이션 교체 — 상세 아키텍처 설계서 (Architect 산출물) + +> 작성: architect (BMAD 2단계) +> 일자: 2026-04-27 +> 입력: `ai-orchestration-replacement-prd.md` (planner) + vexplor 9 서비스 코드 + invyone Spring/Security 코드 +> 산출물 위치: 본 응답 텍스트 (architect는 read-only — 사용자가 동일 내용으로 `C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-architecture.md` 저장 권장) +> 다음 단계: executor + +--- + +## 0. Summary (핵심 결정 5개) + +| # | 결정 영역 | 선택 | 근거 | +|---|---|---|---| +| K1 | **마이그레이션 도구** | **Flyway 10.x** (`org.flywaydb:flyway-core` + `flyway-database-postgresql`) 신규 도입 | invyone 현재 마이그레이션 도구 부재(grep 확인). Spring Boot 3.3.5 starter 자동연동 우수, 멱등 보장, MyBatis와 직교. Liquibase는 XML/YAML 학습비용 큼 | +| K2 | **OpenClaw 통합** | **별도 Docker 컨테이너 + HTTP API 동기화** (공유 볼륨 X) | 컨테이너 격리 → 보안/배포 단순. 공유볼륨은 K8s 운영 시 권한 충돌 빈발. `OPENCLAW_ENABLED=false` 토글로 단계도입 | +| K3 | **LLM 클라이언트** | **Spring `RestClient`(동기) + `WebClient`(스트리밍 SSE)** 직접 구현. **Spring AI는 Phase 2로 보류** | 이미 `AiAssistantProxyService.java:30`이 RestTemplate 사용 → RestClient(동기 후속)는 invyone 표준 라인. Spring AI 1.0 GA는 의존성·BOM 큼, 5개 프로바이더 직접 매핑이 vexplor 동작과 1:1 매칭 더 정확 | +| K4 | **스케줄러** | **Quartz JDBC JobStore** (`spring-boot-starter-quartz`) | vexplor `aiSchedulerService.ts:1`이 `node-cron` 인메모리 사용 — 단일 노드 가정. invyone은 Jenkins 배포 + 추후 멀티 인스턴스 가능성. Quartz JDBC가 클러스터 안전. `@Scheduled`는 cron 표현식 동적 등록/해제 어려움(고정 표현식 한계) | +| K5 | **API 키 인증 필터** | `OncePerRequestFilter` 상속 `AiApiKeyAuthFilter`, **`SubdomainResolverFilter` 뒤 + `JwtAuthenticationFilter` 앞**에 삽입 | 테넌트 컨텍스트 먼저 결정(현재 invyone Phase 2 계약 유지) → API 키 검증 → JWT는 키 없을 때만 처리. SecurityConfig.java:69-71의 기존 필터 체인 보존 | + +**테이블 13개 컬럼 수**: 평균 14컬럼, 합산 ≈ 184컬럼 (아래 §3 DDL). + +**executor가 시작할 첫 Story**: **B1 — Flyway `V20260427_001__ai_baseline.sql` 작성** (Story C1 공통 인프라보다 먼저, 모든 후속 매퍼/엔티티의 contract). + +--- + +## 1. 시스템 다이어그램 + +``` + ┌──────────────────────────────────────────────────────────────┐ + │ 외부 시스템 (B2B) 브라우저 (사내/관리자) │ + │ Authorization: Bearer Authorization: Bearer │ + │ sk-pipe-{64hex} {JWT} │ + └────────────┬──────────────────────────┬──────────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────────────────────────┐ + │ Spring Security FilterChain │ + │ │ + │ CorsFilter → SubdomainResolverFilter (tenant ctx 결정) │ + │ → AiApiKeyAuthFilter ★신규 │ + │ · /api/ai/v1/**만 매칭 │ + │ · sk-pipe-* 발견 시 SHA256 → ai_agent_api_keys │ + │ · last_used_at, usage_count 갱신 │ + │ · 월간 토큰 한도 체크 → 429 │ + │ → JwtAuthenticationFilter (기존) │ + │ → TenantConsistencyGuardFilter │ + │ → ForcePasswordChangeGuardFilter │ + └────────────┬─────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────┐ + │ Controller Layer (com.erp.ai.controller) │ + │ │ + │ /api/ai-agents/** ─ JWT, COMPANY_ADMIN+ │ + │ /api/ai-agent-groups/** ─ JWT, COMPANY_ADMIN+ │ + │ /api/ai/v1/** ─ ApiKey OR JWT (이중 허용) │ + │ /api/ai-agent-schedules/** ─ JWT, COMPANY_ADMIN+ │ + │ /api/ai-knowledge/** ─ JWT, COMPANY_ADMIN+ │ + └────────────┬─────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────┐ + │ Service Layer (com.erp.ai.service) │ + │ │ + │ ┌────────────────────┐ ┌─────────────────────────────┐ │ + │ │ MultiAgentExecution│───▶│ LlmClient (interface) │ │ + │ │ Engine │ │ ├─ AnthropicLlmClient │ │ + │ │ (CompletableFuture │ │ ├─ OpenAiLlmClient │ │ + │ │ + 가상스레드) │ │ ├─ GeminiLlmClient │ │ + │ │ │ │ ├─ DeepSeekLlmClient │ │ + │ │ │ │ └─ OllamaLlmClient │ │ + │ │ │ └────────┬────────────────────┘ │ + │ │ │ │ HTTPS │ + │ │ │ ▼ │ + │ │ │ External LLM Providers │ + │ │ │ │ + │ │ │ ┌─────────────────────────────┐ │ + │ │ │───▶│ Connector executors │ │ + │ │ │ │ (DB / REST / PLC / file) │ │ + │ └─────────┬──────────┘ └─────────────────────────────┘ │ + │ │ │ + │ ├─▶ AiAgentService (CRUD) │ + │ ├─▶ AiAgentGroupService (CRUD + 멤버) │ + │ ├─▶ AiAgentApiKeyService (sk-pipe 발급/검증) │ + │ ├─▶ AiAgentProviderService (AES-GCM 암복호) │ + │ ├─▶ AiAgentConversationSvc (대화 적재) │ + │ ├─▶ AiAgentUsageService (토큰/비용 누적) │ + │ ├─▶ AiAnalysisLogService (이력/정확도) │ + │ ├─▶ AiKnowledgeService (RAG 파일) │ + │ ├─▶ AiSchedulerService (Quartz JDBC) │ + │ └─▶ OpenClawSyncService (HTTP 동기화) │ + │ │ │ + │ ▼ │ + │ ┌─────────────────────┐ │ + │ │ OpenClawClient │ │ + │ │ (RestClient → :18789)│ │ + │ └─────────┬───────────┘ │ + └────────────┬─────────────────┼─────────────────────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌─────────────────────────────────┐ + │ MyBatis Mapper Layer │ │ openclaw 컨테이너 (별도 docker) │ + │ (com.erp.ai.mapper) │ │ · port 18789 │ + └──────────┬───────────┘ │ · authProfiles + agents JSON │ + │ │ · /v1/sync 엔드포인트(가정) │ + ▼ └─────────────────────────────────┘ + ┌──────────────────────────────────────────────────────────────┐ + │ PostgreSQL 16 (invyone @ 183.99.177.40) │ + │ schema: invyone │ + │ ai_agents · ai_agent_groups · ai_agent_group_members │ + │ ai_agent_api_keys · ai_llm_providers · ai_agent_messages │ + │ ai_agent_conversations · ai_agent_usage_logs │ + │ ai_agent_schedules · ai_analysis_logs · ai_knowledge_files │ + │ QRTZ_* (Quartz 11 테이블) │ + └──────────────────────────────────────────────────────────────┘ +``` + +**데이터 저장 흐름 (그룹 실행)**: 외부 호출 → ApiKey 검증(`ai_agent_api_keys` UPDATE) → MultiAgentExecutionEngine.execute → 멤버별 LlmClient 호출 → `ai_agent_conversations` INSERT + `ai_agent_messages` INSERT × N + `ai_analysis_logs` INSERT + `ai_agent_usage_logs` INSERT. + +--- + +## 2. 패키지 구조 + +``` +com.erp.ai +├── controller/ +│ ├── AiAgentController.java // /api/ai-agents — JWT +│ ├── AiAgentGroupController.java // /api/ai-agent-groups (+ /:id/members, /connectors) +│ ├── AiAgentApiKeyController.java // /api/ai-agents/keys — JWT +│ ├── AiAgentProviderController.java // /api/ai-agents/providers — SUPER_ADMIN +│ ├── AiAgentConversationController.java // /api/ai-agents/conversations +│ ├── AiAgentUsageController.java // /api/ai-agents/usage +│ ├── AiAgentScheduleController.java // /api/ai-agent-schedules +│ ├── AiKnowledgeController.java // /api/ai-knowledge +│ └── AiV1GatewayController.java // /api/ai/v1/* — ApiKey OR JWT (외부 호환) +├── service/ +│ ├── AiAgentService.java // 에이전트 CRUD/검색/필터 +│ ├── AiAgentGroupService.java // 그룹 + 멤버 + 가용 커넥터 +│ ├── AiAgentApiKeyService.java // sk-pipe 발급/검증/한도 +│ ├── AiAgentProviderService.java // 프로바이더 + AES-GCM +│ ├── AiAgentConversationService.java // 대화 + 메시지 +│ ├── AiAgentUsageService.java // 사용량/요약/일별 +│ ├── AiAnalysisLogService.java // 분석이력/정확도 +│ ├── AiKnowledgeService.java // RAG 지식 파일 +│ ├── AiSchedulerService.java // Quartz Job 등록/해제 +│ ├── OpenClawSyncService.java // 프로바이더/에이전트 → OpenClaw +│ └── MultiAgentExecutionEngine.java // 동시성 핵심 (자세한 §7) +├── client/ +│ ├── LlmClient.java // 추상 인터페이스 +│ ├── llm/ +│ │ ├── AnthropicLlmClient.java // /v1/messages +│ │ ├── OpenAiLlmClient.java // /v1/chat/completions +│ │ ├── GeminiLlmClient.java // /v1beta/models/.../generateContent +│ │ ├── DeepSeekLlmClient.java // OpenAI compat +│ │ └── OllamaLlmClient.java // /api/chat +│ ├── LlmClientRouter.java // model 명으로 구현체 선택 +│ └── OpenClawClient.java // RestClient → :18789 +├── connector/ +│ ├── Connector.java // sealed interface +│ ├── DatabaseConnector.java +│ ├── RestApiConnector.java +│ ├── FileConnector.java +│ ├── CrawlerConnector.java +│ ├── PlcConnector.java +│ └── ConnectorExecutor.java // type → 구현체 라우팅 +├── scheduler/ +│ ├── AiAgentScheduledJob.java // org.quartz.Job 구현체 +│ └── QuartzSchedulerConfig.java +├── security/ +│ ├── AiApiKeyAuthFilter.java // OncePerRequestFilter +│ ├── ApiKeyAuthenticationToken.java // Authentication 구현체 +│ └── ApiKeyPrincipal.java // 인증 주체 +├── config/ +│ ├── AiSecurityConfig.java // SecurityFilterChain @Order(2) — /api/ai/v1/** +│ ├── OpenClawProperties.java // @ConfigurationProperties("openclaw") +│ ├── LlmHttpClientConfig.java // RestClient/WebClient bean +│ ├── AiExecutorConfig.java // 가상스레드 ExecutorService bean +│ └── FlywayConfig.java // 신규 마이그레이션 위치 등록 (필요시) +├── crypto/ +│ ├── EncryptUtil.java // AES-256-GCM (12B IV + 16B tag) +│ └── EncryptionProperties.java // @ConfigurationProperties("ai.encryption") +├── tenant/ +│ └── CompanyCodeFilter.java // company_code 강제 주입 헬퍼 (정적 메서드) +├── mapper/ // MyBatis (interface) +│ ├── AiAgentMapper.java +│ ├── AiAgentGroupMapper.java +│ ├── AiAgentGroupMemberMapper.java +│ ├── AiAgentApiKeyMapper.java +│ ├── AiLlmProviderMapper.java +│ ├── AiAgentConversationMapper.java +│ ├── AiAgentMessageMapper.java +│ ├── AiAgentUsageLogMapper.java +│ ├── AiAgentScheduleMapper.java +│ ├── AiAnalysisLogMapper.java +│ └── AiKnowledgeFileMapper.java +├── domain/ // immutable record (Lombok @Builder) +│ ├── AiAgent.java +│ ├── AiAgentGroup.java +│ ├── AiAgentGroupMember.java +│ ├── AiAgentApiKey.java +│ ├── AiLlmProvider.java +│ ├── AiAgentConversation.java +│ ├── AiAgentMessage.java +│ ├── AiAgentUsageLog.java +│ ├── AiAgentSchedule.java +│ ├── AiAnalysisLog.java +│ ├── AiKnowledgeFile.java +│ ├── ConnectorRef.java // type / connection_id / config_id / path / name +│ └── ExecutionResult.java +├── dto/ +│ ├── request/ // CreateAgentRequest, CreateApiKeyRequest … +│ └── response/ // AgentDto, GroupDto, UsageSummaryDto … +└── exception/ + ├── AiBusinessException.java // 422 + ├── AiNotFoundException.java // 404 + ├── AiUnauthorizedException.java // 401 + ├── AiQuotaExceededException.java // 429 + └── AiRestExceptionHandler.java // @RestControllerAdvice +``` + +리소스: +``` +backend-spring/src/main/resources/ +├── mapper/ai/ // MyBatis XML 11개 +└── db/migration/ // Flyway + ├── V20260427_001__ai_baseline.sql + ├── V20260427_002__ai_indexes.sql + ├── V20260427_003__ai_seed_providers.sql + ├── V20260427_004__quartz_tables.sql // org.quartz tables-postgres.sql 복사 + └── V20260427_005__drop_ai_assistant_db.sql // (해당 DB 별도 호스트면 main-spring 미적용 가능) +``` + +--- + +## 3. DB 스키마 (DDL — PostgreSQL 16) + +> 11개 핵심 테이블 + Quartz 11개 테이블. (planner가 13개 추정 → 코드 역공학 결과 11개로 정확화: `ai_agent_tools` P3 미포함, `ai_agent_knowledge_*`은 `ai_knowledge_files`로 단일화. P2 RAG 라이브러리/멤버십 분리 시 추가 예정) +> +> 모든 테이블: `company_code VARCHAR(20)` NULL 허용(`NULL` 또는 `'*'` = 슈퍼관리자/전체 공유), `created_at`/`updated_at` `TIMESTAMPTZ DEFAULT NOW()`, FK는 `RESTRICT` 기본, 명시 시 `CASCADE`. + +### 3.1 `ai_agents` (출처: aiAgentService.ts:43-58, types/aiAgent.ts:3-17) + +```sql +CREATE TABLE ai_agents ( + id BIGSERIAL PRIMARY KEY, + agent_id VARCHAR(64) NOT NULL UNIQUE, -- aiAgentService.ts:38 unique check + 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, -- aiAgentService.ts:53 + config JSONB NOT NULL DEFAULT '{}'::jsonb, -- knowledge_files, max_tokens, temperature + status VARCHAR(20) NOT NULL DEFAULT 'active' -- active/inactive/archived + CHECK (status IN ('active','inactive','archived')), + company_code VARCHAR(20), -- NULL 또는 '*' = 글로벌 + 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); +``` + +### 3.2 `ai_agent_groups` (출처: aiAgentGroupService.ts:71-79) + +```sql +CREATE TABLE ai_agent_groups ( + id BIGSERIAL PRIMARY KEY, + group_id VARCHAR(64) NOT NULL UNIQUE, -- 'group-{base36}' + name VARCHAR(200) NOT NULL, + description TEXT, + execution_mode VARCHAR(20) NOT NULL DEFAULT 'mixed' -- parallel/sequential/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); +``` + +### 3.3 `ai_agent_group_members` (출처: aiAgentGroupService.ts:117-119, multiAgentExecutionEngine.ts:60-65) + +```sql +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, -- ConnectorRef[] + execution_order INTEGER NOT NULL DEFAULT 1, -- mixed 모드 핵심 + 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); +``` + +### 3.4 `ai_agent_api_keys` (출처: aiAgentApiKeyService.ts:30-46, types/aiAgent.ts:40-57) + +```sql +CREATE TABLE ai_agent_api_keys ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + key_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 hex + key_prefix VARCHAR(16) NOT NULL, -- 'sk-pipe-{8hex}' 표시용 + 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, -- req/min + 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); +``` + +> **결정 (open-question Q7)**: `status='revoked'` 컬럼은 두되, vexplor의 hard-delete(aiAgentApiKeyService.ts:54) 동작은 그대로 옮긴다. 이유: ai_agent_usage_logs.api_key_id FK 무결성 + 추후 감사로그 옵션 확보. 초기 구현은 `DELETE`, 나중에 정책 변경 시 컬럼만 활용. + +### 3.5 `ai_llm_providers` (출처: aiAgentProviderService.ts:28-42, types/aiAgent.ts:113-129) + +```sql +CREATE TABLE ai_llm_providers ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, -- anthropic/openai/google/deepseek/ollama + display_name VARCHAR(200) NOT NULL, + api_key_encrypted TEXT NOT NULL, -- AES-256-GCM base64(IV||CT||TAG) + 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); +``` + +### 3.6 `ai_agent_conversations` (출처: aiAgentConversationService.ts:56-58, multiAgentExecutionEngine.ts:99-104) + +```sql +CREATE TABLE ai_agent_conversations ( + id BIGSERIAL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL UNIQUE, -- 'conv-{uuid}' + 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, -- group_id, group_name, execution_mode + 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); +``` + +### 3.7 `ai_agent_messages` (출처: aiAgentConversationService.ts:67-69) + +```sql +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, -- multiAgentExecutionEngine.ts:115 (role_name, agent_name, ...) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_msg_conv ON ai_agent_messages (conversation_id, created_at); +``` + +### 3.8 `ai_agent_usage_logs` (출처: aiAgentUsageService.ts:8-9, types/aiAgent.ts:93-111) + +```sql +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), -- NEW: tenancy 추적 (vexplor 미존재, invyone 강화) + 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); +``` + +### 3.9 `ai_agent_schedules` (출처: aiSchedulerService.ts:40-42, 102) + +```sql +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, -- Quartz 6-7필드 또는 5필드 + timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Seoul', -- open-question 12 결정 + input_message TEXT NOT NULL, + notification JSONB NOT NULL DEFAULT '{}'::jsonb, -- {system_notice, webhook, email[]} + 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); +``` + +### 3.10 `ai_analysis_logs` (출처: aiAnalysisLogService.ts:21-30, 44, 57, 67) + +```sql +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 -- manual/api/schedule + 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), -- 0~100, 백분율 + 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); +``` + +### 3.11 `ai_knowledge_files` (출처: knowledgeRoutes.ts:15, 55) + +```sql +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); +``` + +### 3.12 Quartz 영구 테이블 (Story C10용) + +`org.quartz:quartz` 의존성에 포함된 `tables_postgres.sql` 스크립트 11개 테이블(`QRTZ_JOB_DETAILS`, `QRTZ_TRIGGERS`, `QRTZ_CRON_TRIGGERS` …)을 별도 마이그레이션 파일 `V20260427_004__quartz_tables.sql` 로 적용. invyone의 멀티 인스턴스 배포(클러스터링) 안전. + +### 3.13 invyone 의 vexplor 커넥터 테이블 — **부재 확인** (open-question 6) + +검색 결과(`grep CREATE TABLE.*external_db_connections`) invyone 본 DB에 다음 테이블 없음: +- `external_db_connections` (단, **`ExternalRestApiConnectionService.java` 발견** — 동일 의도 신규 테이블 존재 가능성) +- `external_rest_api_connections` +- `crawl_configs` +- `pipeline_device_connections` / `pipeline_tag_mappings` + +**결정**: Story C3 "AiAgentGroupService.getAvailableConnectors" 는 **try/catch + empty fallback** 으로 구현(vexplor `aiAgentGroupService.ts:158, 163, 168, 176` 의 `.catch(() => [])` 패턴 그대로 옮김). 테이블 미존재 시 빈 배열만 반환되고 화면은 "사용 가능한 커넥터 없음" 표시. 추후 `ExternalRestApi*`/PLC 도입 시 mapping 추가. **이 결정으로 P0 차단 없음**. + +--- + +## 4. API 명세 + +> 모든 응답: `{ success: boolean, data?: , message?: string, error?: string }` (invyone 표준). + +### 4.1 관리 (JWT, COMPANY_ADMIN+) + +| 메서드 | 경로 | 요청 | 응답 | 비고 | +|---|---|---|---|---| +| GET | `/api/ai-agents` | `?status&company_code&search` | `AgentDto[]` | company_code 자동 주입(JWT claim) | +| GET | `/api/ai-agents/:id` | — | `AgentDto` | | +| POST | `/api/ai-agents` | `CreateAgentRequest` | `AgentDto` | agent_id unique | +| PUT | `/api/ai-agents/:id` | `UpdateAgentRequest` | `AgentDto` | | +| DELETE | `/api/ai-agents/:id` | — | `{success:true}` | soft (status=archived) | +| GET | `/api/ai-agent-groups` | — | `GroupDto[]` (with member_count) | | +| GET | `/api/ai-agent-groups/:id` | — | `GroupDto` (with members JOIN) | | +| POST | `/api/ai-agent-groups` | `{name, description, execution_mode}` | `GroupDto` | | +| PUT/DELETE | `/api/ai-agent-groups/:id` | | | | +| POST | `/api/ai-agent-groups/:id/members` | `AddMemberRequest` | `MemberDto` | | +| PUT/DELETE | `/api/ai-agent-groups/members/:memberId` | | | | +| GET | `/api/ai-agent-groups/connectors` | — | `ConnectorDto[]` | invyone에 테이블 없으면 [] | +| GET | `/api/ai-agents/keys` | — | `ApiKeyDto[]` | admin: 전체, user: 자기 | +| POST | `/api/ai-agents/keys` | `CreateApiKeyRequest` | `{key:ApiKeyDto, plainKey}` | **plainKey 1회만** | +| DELETE | `/api/ai-agents/keys/:id` | — | | | +| GET/POST/PUT/DELETE | `/api/ai-agents/providers/*` | | | SUPER_ADMIN only | +| GET | `/api/ai-agents/conversations` | `?page&limit&agentId` | `{conversations, total}` | | +| GET | `/api/ai-agents/conversations/:id` | — | `{conversation, messages}` | | +| DELETE | `/api/ai-agents/conversations/:id` | — | | | +| GET | `/api/ai-agents/usage/summary` | — | `UsageSummaryDto` | | +| GET | `/api/ai-agents/usage/logs` | `?page&limit` | `{logs,total}` | | +| GET | `/api/ai-agents/usage/daily` | `?days=30` | `DailyUsageDto[]` | | +| CRUD | `/api/ai-agent-schedules/*` | | | cron 검증 | +| CRUD | `/api/ai-knowledge/*` | | | 카테고리 필터 | + +### 4.2 외부 게이트웨이 (ApiKey OR JWT, OpenAI 호환) + +| 메서드 | 경로 | 요청 | 응답 | +|---|---|---|---| +| POST | `/api/ai/v1/chat/completions` | OpenAI 표준 (`model, messages, stream, max_tokens, temperature`) | OpenAI 응답 + `stream=true` 시 SSE (`text/event-stream`) | +| POST | `/api/ai/v1/groups/:groupId` | `{message}` | `GroupExecutionResultDto` | +| GET | `/api/ai/v1/groups` | — | `GroupDto[]` (active만) | +| GET | `/api/ai/v1/models` | — | OpenAI 모델 형식 (`{data:[{id,object,...}]}`) | +| GET | `/api/ai/v1/health` | — | `{status:'ok', providers:N, openclaw:bool}` | + +429: 월간 토큰 한도 초과. 401: 키 없음/만료. 403: permissions에 `chat` 미포함. 503: OpenClaw 다운(외부 호출만). + +--- + +## 5. 인증/보안 설계 + +### 5.1 필터 체인 통합 + +```java +// SecurityConfig.java (수정) +http.addFilterBefore(new AiApiKeyAuthFilter(aiAgentApiKeyService), + JwtAuthenticationFilter.class) +``` + +- 기존 6개 필터 사이 위치: `SubdomainResolverFilter` (tenant ctx) → **`AiApiKeyAuthFilter` (신규, /api/ai/v1/** 만 매칭)** → `JwtAuthenticationFilter` → `TenantConsistencyGuardFilter` → `ForcePasswordChangeGuardFilter`. +- `AiApiKeyAuthFilter` 가 sk-pipe-* 발견 시 SecurityContext 채우고 통과; 못 찾으면 그대로 다음 필터로 위임 (이중 인증 = JWT 도 OK). + +### 5.2 ApiKey 검증 흐름 + +``` +1. Authorization: Bearer +2. token.startsWith("sk-pipe-") 검사 → 미일치면 chain.doFilter (JWT 처리) +3. SHA-256(token) 계산 → ai_agent_api_keys.key_hash 조회 +4. status='active' & expires_at>NOW 확인 (실패 시 401) +5. permissions JSONB 에 'chat' 포함 검사 (없으면 403) +6. 월간 토큰 누적 vs monthly_token_limit 검사 (초과 시 429) +7. UPDATE last_used_at, usage_count++ +8. SecurityContext = ApiKeyAuthenticationToken(ApiKeyPrincipal{keyId, userId, companyCode, agentId}) +9. chain.doFilter +``` + +→ 컨트롤러는 `@AuthenticationPrincipal ApiKeyPrincipal` 또는 `request.getAttribute("api_key_id")` 로 식별. + +### 5.3 LLM 프로바이더 키 암호화 + +**선택: AES-256-GCM (Bouncy Castle)** vs Jasypt → **AES-GCM 직접 구현**. + +- 근거: Jasypt는 PBE/AES-CBC 위주, GCM 지원 약함. AES-GCM은 인증 태그가 평문 변조 검출 가능 → 키 위변조 방지에 강함. +- JDK 21 내장 `javax.crypto.Cipher` + `GCMParameterSpec` 만으로 구현 가능 (Bouncy Castle 의존성 없이도 됨). +- 형식: `base64( IV(12B) || CIPHERTEXT || TAG(16B) )` +- 키 관리(open-question 8): **환경변수 `AI_PROVIDER_ENCRYPTION_KEY`** (Base64 32B). KMS는 Phase 2. + +```java +// EncryptUtil.java 핵심 — 의사코드 +public static String encrypt(String plain) { + byte[] iv = SecureRandom.getInstanceStrong().generateSeed(12); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(ENCRYPT_MODE, keyFromEnv(), new GCMParameterSpec(128, iv)); + byte[] ct = c.doFinal(plain.getBytes(UTF_8)); + return Base64.encode(concat(iv, ct)); +} +``` + +### 5.4 멀티테넌시 강제 + +**선택: 명시적 BaseService 패턴** (AOP 어노테이션은 invyone에 선례 없음, 학습 비용↑). + +```java +public abstract class CompanyScopedService { + protected String currentCompanyCode() { + var attr = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) + .getRequest().getAttribute("company_code"); + if (attr == null) throw new AiBusinessException("company_code 누락"); + return (String) attr; + } +} +``` + +모든 list/get/update/delete 매퍼 호출 시 `companyCode` 인자 강제. SQL: `WHERE (company_code = #{companyCode} OR company_code IS NULL OR company_code = '*')`. SUPER_ADMIN 만 `companyCode='*'` 그대로 통과 가능. + +--- + +## 6. OpenClaw 통합 방식 + +### 6.1 배포 (open-question 2) + +**선택: docker-compose 별도 컨테이너**, 이미지 출처는 사용자 확인 필요. + +```yaml +# docker-compose.backend.win.yml 추가 부분 + openclaw: + image: ${OPENCLAW_IMAGE:-openclaw/openclaw:latest} # ← 사용자 확인 필요 (Q1) + container_name: invyone-openclaw + ports: + - "18789:18789" + environment: + - OPENCLAW_PORT=18789 + - OPENCLAW_LOG_LEVEL=info + volumes: + - openclaw-config:/root/.openclaw # config 영속 + restart: unless-stopped + profiles: ["full"] # OPENCLAW_ENABLED=false 시 제외 +volumes: + openclaw-config: +``` + +> **사용자 확인 필요 (Q-A)**: OpenClaw이 (1) 오픈소스 npm 패키지인가? (2) Docker Hub 공식 이미지가 있는가? (3) vexplor 사내 빌드 이미지인가? Architect는 (3)로 가정하고 설계, 사용자가 다르면 Story E1에서 실제 이미지 경로/Dockerfile 확정. + +### 6.2 HTTP 클라이언트 (open-question) + +**선택: Spring `RestClient`** (`spring-boot-starter-web` 자동 포함, JDK 21 친화). + +근거: invyone 기존 `AiAssistantProxyService.java:30` 가 RestTemplate 사용 → RestClient는 RestTemplate의 후속이며 같은 동기 모델, 코드 패턴 일관성. WebClient는 Reactor 도입으로 의존성·인지비용 큼 → SSE 스트리밍에만 한정 사용. + +```java +@Configuration +public class LlmHttpClientConfig { + @Bean + public RestClient openClawRestClient(OpenClawProperties props) { + return RestClient.builder() + .baseUrl(props.getBaseUrl()) // http://openclaw:18789 + .requestFactory(new JdkClientHttpRequestFactory()) // HTTP/2 + .defaultHeader("X-Pipeline-Source", "invyone") + .build(); + } +} +``` + +### 6.3 Config 동기화 방식 (open-question 3) + +**선택: HTTP API**. 공유 볼륨 X. + +- 근거: 공유 볼륨은 K8s 운영 시 `ReadWriteMany` 필수 → NFS/CephFS 의존성, 권한 충돌 잦음. HTTP API는 stateless, K8s 어디서든 작동. +- 가정: OpenClaw에 `POST /v1/sync/auth-profiles`, `POST /v1/sync/agents` 엔드포인트 존재 또는 OpenClaw 측에 어댑터 추가. +- 만약 OpenClaw이 HTTP sync 미지원이면 **Plan B: Spring 서비스가 mounted 볼륨에 JSON 파일 직접 작성** (vexplor 동일 동작). 이는 Story E4에서 OpenClaw API 스펙 확인 후 결정. + +### 6.4 헬스체크/회로차단 + +- **헬스체크**: 30초마다 `GET /v1/health` (Spring `@Scheduled(fixedRate=30000)` 단순). 결과 → `OpenClawHealthState` Bean. +- **회로차단**: **Resilience4j 도입 권장하나 선택**. P1 위험: OpenClaw 다운 시 LLM 호출 차단 회피. + - 단순 fallback: `OpenClawClient` 호출 실패 시 → invyone 자체 `LlmClient` 직접 호출 (vexplor 미존재, invyone 강화 항목) + - 외부 호출 게이트웨이는 OpenClaw 의존 X (LlmClient 직접). OpenClaw 다운은 관리화면 영향만. +- 503: OpenClawSyncService.sync 호출 시 OpenClaw 다운이면 큐 → 다음 health up 시 재시도 (Phase 2). + +--- + +## 7. 멀티 에이전트 실행 엔진 설계 + +### 7.1 동시성 모델 + +**선택: JDK 21 가상 스레드 + `CompletableFuture`**. + +```java +@Configuration +public class AiExecutorConfig { + @Bean("aiAgentExecutor") + public ExecutorService aiAgentExecutor() { + // 가상 스레드 = 메모리 cheap, 수천개 OK. LLM 호출은 IO-bound라 정확한 매칭 + return Executors.newVirtualThreadPerTaskExecutor(); + } +} +``` + +### 7.2 핵심 로직 (vexplor multiAgentExecutionEngine.ts:41-154 Java 등가) + +```java +public CompletableFuture execute(long groupId, String userMessage, Options opts) { + var group = groupService.getById(groupId).orElseThrow(NotFoundException::new); + if (group.members().isEmpty()) throw new BusinessException("그룹에 에이전트 없음"); + + // 1. 과거 분석 이력 컨텍스트 — try/catch 무시 (vexplor 70라인 동일) + String historyContext = analysisLogService.buildHistoryContext(groupId); + String enriched = userMessage + (historyContext.isEmpty() ? "" : "\n\n" + historyContext); + + // 2. 모드별 분기 — Future 반환 + return switch (group.executionMode()) { + case "parallel" -> executeParallel(group.members(), enriched, ""); + case "sequential" -> executeSequential(group.members(), enriched); + default -> executeMixed(group.members(), enriched); + }.thenCompose(results -> { + // 3. 사이드 이펙트 — REQUIRES_NEW 트랜잭션 (대화/이력/사용량 적재) + return CompletableFuture.supplyAsync(() -> + persistResults(group, userMessage, results, opts), aiAgentExecutor); + }); +} + +private CompletableFuture> executeParallel(List members, String msg, String prev) { + var futures = members.stream() + .map(m -> CompletableFuture.supplyAsync(() -> executeSingleAgent(m, msg, prev), aiAgentExecutor)) + .toList(); + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .thenApply(v -> futures.stream().map(CompletableFuture::join).toList()); +} + +private CompletableFuture> executeMixed(List members, String msg) { + // execution_order 로 그룹핑 → order 정렬 순회 → 각 order 내부는 parallel + var byOrder = members.stream() + .collect(Collectors.groupingBy(Member::executionOrder, TreeMap::new, Collectors.toList())); + + CompletableFuture> chain = CompletableFuture.completedFuture(List.of()); + StringBuilder previousCtx = new StringBuilder(); + for (var entry : byOrder.entrySet()) { + var order = entry.getValue(); + chain = chain.thenCompose(prev -> { + String ctx = previousCtx.toString(); + CompletableFuture> stage = order.size() == 1 + ? CompletableFuture.supplyAsync(() -> List.of(executeSingleAgent(order.get(0), msg, ctx)), aiAgentExecutor) + : executeParallel(order, msg, ctx); + return stage.thenApply(stageResults -> { + stageResults.forEach(r -> previousCtx.append(format(r))); + var combined = new ArrayList<>(prev); combined.addAll(stageResults); return combined; + }); + }); + } + return chain; +} +``` + +### 7.3 트랜잭션 경계 + +- `execute()` 자체는 **트랜잭션 없음** (LLM 호출 60초 → connection lease 위험) +- `persistResults` 만 `@Transactional(propagation = REQUIRES_NEW, timeout = 30)` +- `addMessage` 루프는 단일 트랜잭션으로 묶음 (메시지 N개 atomic) — 부분 실패 방지 + +### 7.4 커넥터 추상화 + +```java +public sealed interface Connector permits DatabaseConnector, RestApiConnector, FileConnector, CrawlerConnector, PlcConnector { + Map execute(ConnectorRef ref); + String type(); +} + +@Component +public class ConnectorExecutor { + private final Map registry; // Spring 주입 (type → impl) + + public Map execute(ConnectorRef ref) { + var c = registry.get(ref.type()); + if (c == null) return Map.of("error", "지원하지 않는 커넥터 타입"); + try { return c.execute(ref); } + catch (Exception e) { return Map.of("error", e.getMessage()); } + } +} +``` + +각 커넥터 구현은 vexplor `executeConnector` (multiAgentExecutionEngine.ts:356-466) 분기 그대로 옮김. invyone에 테이블 없으면 `Map.of("info", "준비됨")` 반환 (vexplor의 `info: "커넥터 연결 준비됨"` 동작과 동일). + +### 7.5 결과 합성 + 토큰 집계 + +`buildFinalSummary` 동일. `totalTokens` 는 stream sum. 사용량 적재는 `AiAgentUsageService.log` 단일 row (vexplor:134 와 동일). API 키 누적은 `addTokenUsage` 별도 호출 (`UPDATE ai_agent_api_keys SET total_tokens = total_tokens + ?`) — PostgreSQL row-level lock 으로 원자성 보장 (Redis 불필요). + +### 7.6 타임아웃 / 부분 실패 + +- LLM 호출: `RestClient` 60초 read timeout. 스트리밍은 무제한. +- `executeSingleAgent` 내부 try/catch (vexplor:309-350 동일) → 실패 시 `[실행 실패] {error}` 응답으로 step에 기록, 그룹 전체 실패 X. +- `executeMixed` 의 한 order 가 부분 실패 시 다음 order는 계속 진행 (현 vexplor 동작 보존). + +--- + +## 8. 스케줄러 설계 + +### 8.1 선택: Quartz JDBC JobStore (open-question 5) + +**근거**: +| 항목 | `@Scheduled` | Quartz JDBC | +|---|---|---| +| cron 동적 등록/해제 | 어려움 (TaskScheduler 직접 다뤄야) | 자연스러움 | +| 클러스터 안전 | 없음 (각 노드가 따로 실행) | `org.quartz.jobStore.isClustered=true` | +| 영속 (재기동 후 부활) | 매번 DB 조회 + `@PostConstruct` 재등록 | 자동 | +| 학습 비용 | 낮음 | 중간 | +| invyone 멀티 인스턴스 가능성 | — | ★유리 | + +vexplor `aiSchedulerService.ts` 의 `Map activeJobs` 는 인메모리 → 노드 죽으면 소실. invyone Jenkins 배포 + 멀티 노드 가능성에서 위험. + +### 8.2 동기화 전략 + +```java +@Component +public class AiAgentScheduledJob implements Job { + @Override + public void execute(JobExecutionContext ctx) { + long scheduleId = ctx.getMergedJobDataMap().getLong("scheduleId"); + var schedule = scheduleService.getById(scheduleId).orElseThrow(); + var result = executionEngine.execute(schedule.groupId(), schedule.inputMessage(), + new Options(schedule.createdBy(), null)).join(); + scheduleService.markRun(scheduleId); + notificationService.dispatch(schedule, result); + } +} +``` + +`AiSchedulerService.create/update/delete` 는 DB 쓰기 + `Scheduler.scheduleJob/unscheduleJob` 호출. 서버 시작 시 `@PostConstruct` 로 활성 스케줄 fetch → Quartz job 등록 (vexplor `initializeSchedules` 동일). + +### 8.3 알림 (vexplor 동작 보존) + +- 시스템 공지: `INSERT INTO system_notice` (invyone 기존 테이블 — Story C10 사전 확인 필요) +- 웹훅: `RestClient.post(webhookUrl, {text:...})` timeout 10초 +- 이메일: invyone 기존 `MailSendService` 재사용 (PRD §3.8 참고) + +### 8.4 Cron 타임존 (open-question 12) + +**선택: `Asia/Seoul`** (스케줄 row의 `timezone` 컬럼). Quartz `CronTrigger.timeZone` 에 주입. UTC 저장은 운영자 cron 작성 헷갈림. + +--- + +## 9. LLM 클라이언트 추상화 + +### 9.1 선택: Spring AI 미도입, 직접 구현 (open-question 4) + +**근거**: +| | Spring AI | 직접 구현 | +|---|---|---| +| 의존성 크기 | 큼 (Reactor, AOT support) | 작음 (RestClient만) | +| 5개 프로바이더 정확 매칭 | Anthropic/OpenAI는 OK, DeepSeek/Ollama는 직접 작성 필요 | 모두 1:1 | +| 스트리밍 | Flux (Reactor) | SSE 직접 처리 | +| vexplor `LlmClient.chatCompletion` 1:1 포팅 | 어댑터 변환 필요 | 직접 | +| 1.0 GA 안정성 | 2024-08 GA, 빠른 변경 | 안정 | + +→ **Phase 1: 직접 구현**. **Phase 2: Spring AI MCP 도입 검토** (인터페이스 추상화로 교체 비용 감소). + +### 9.2 인터페이스 + +```java +public interface LlmClient { + LlmResponse chat(LlmRequest request); + Flux stream(LlmRequest request); // SSE만 WebFlux/Flux + boolean supports(String model); // "claude-*", "gpt-*", "gemini-*" +} + +@Component +public class AnthropicLlmClient implements LlmClient { + private final RestClient client; // baseUrl=https://api.anthropic.com + public boolean supports(String model) { return model.startsWith("claude-"); } + public LlmResponse chat(LlmRequest req) { + var body = Map.of( + "model", req.model(), + "messages", req.messages().stream().filter(m -> !m.role().equals("system")).toList(), + "system", req.systemPrompt(), + "max_tokens", req.maxTokens() + ); + var resp = client.post().uri("/v1/messages") + .header("x-api-key", decryptedKey) + .header("anthropic-version", "2023-06-01") + .body(body).retrieve().body(AnthropicResponse.class); + return LlmResponse.fromAnthropic(resp); + } +} +``` + +### 9.3 라우팅 + +```java +@Component +public class LlmClientRouter { + private final List clients; + public LlmClient pick(String model) { + return clients.stream().filter(c -> c.supports(model)) + .findFirst().orElseThrow(() -> new BusinessException("지원하지 않는 모델: " + model)); + } +} +``` + +### 9.4 스트리밍 (open-question 9) + +**선택: Spring MVC SSE** (`MediaType.TEXT_EVENT_STREAM`) + 내부 `WebClient` 로 LLM 스트림 수신 → SseEmitter 로 클라이언트 전송. + +근거: WebFlux 전체 도입은 invyone 기존 MVC 패러다임과 충돌 (95개 컨트롤러). MVC + SseEmitter 조합으로 단일 엔드포인트만 reactive 처리 가능. + +```java +@PostMapping(value="/chat/completions", produces=MediaType.TEXT_EVENT_STREAM_VALUE) +public SseEmitter stream(@RequestBody LlmRequest req) { + var emitter = new SseEmitter(0L); // 무제한 + aiExecutor.submit(() -> { + try { + llmRouter.pick(req.model()).stream(req) + .doOnNext(chunk -> emitter.send(SseEmitter.event().data(chunk))) + .doOnError(emitter::completeWithError) + .doOnComplete(emitter::complete) + .blockLast(); + } catch (Exception e) { emitter.completeWithError(e); } + }); + return emitter; +} +``` + +--- + +## 10. 동시성/성능 + +| 항목 | 결정 | 근거 | +|---|---|---| +| 에이전트 실행 풀 | `newVirtualThreadPerTaskExecutor()` | IO-bound, JDK 21 | +| 큐 백프레셔 | 가상 스레드 자체로 OOM 위험 — 논리적 limit `Semaphore(200)` | 동시 그룹 실행 200개 상한 | +| 토큰 누적 원자성 | PostgreSQL `UPDATE … SET total_tokens = total_tokens + ?` | row-level lock 충분, Redis 불필요 | +| Rate limit (open-question 10) | **Bucket4j 인메모리** Phase 1, **Redis Bucket4j** Phase 2 | NFR `분당 100 req 미만` 가정 (open-questions.md 33) → 인메모리 충분 | +| HTTP keep-alive | `JdkClientHttpRequestFactory` 기본 풀 | LLM 호출 connection reuse | +| MyBatis 풀 | 현재 invyone 설정 그대로 (수동 조정 X) | 가상 스레드 + HikariCP 호환 (Spring Boot 3.3+ 정상) | + +--- + +## 11. 마이그레이션 전략 + +### 11.1 도구: Flyway 10.x (open-question 1) + +```gradle +// backend-spring/build.gradle 추가 +implementation 'org.flywaydb:flyway-core:10.20.1' +runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.20.1' +``` + +```yaml +# application.yml +spring: + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true # 기존 invyone 운영 DB는 baseline 처리 + baseline-version: 0 + schemas: invyone + placeholders: + ai_company_default: '*' +``` + +### 11.2 마이그레이션 파일 + +| 파일 | 내용 | +|---|---| +| `V20260427_001__ai_baseline.sql` | §3.1~3.11 11개 테이블 CREATE TABLE | +| `V20260427_002__ai_indexes.sql` | 인덱스 추가 (별도 파일로 롤백 용이) | +| `V20260427_003__ai_seed_providers.sql` | placeholder providers 5개 row (is_active=false, 사용자가 콘솔에서 활성) | +| `V20260427_004__quartz_tables.sql` | Quartz 11개 테이블 | +| `V20260427_005__cleanup_ai_assistant.sql` | (조건부) `DROP DATABASE IF EXISTS ai_assistant_db` — invyone DB 호스트와 별개면 SQL 미적용, 별도 운영 스크립트로 | + +### 11.3 롤백 + +- Flyway는 자체 down 스크립트 미제공 → `U20260427_001__rollback.sql` 수동 작성 (DROP TABLE 11개 + DROP INDEX). dev/staging만 사용. +- 운영은 baseline 시점 DB 백업 → 복구. + +--- + +## 12. 테스트 전략 (open-question 11) + +### 12.1 단위 테스트 +- **Testcontainers PostgreSQL 16** + **MyBatis SqlSessionFactoryBean**: 매퍼 SQL 정합성. +- **WireMock**: LLM 응답 스텁 (anthropic 200 OK + JSON, openai 429 등). +- 서비스 클래스: Mockito + DI 주입, MyBatis 매퍼 mock. +- 핵심 시나리오: ApiKey SHA-256 일치, AES-GCM round-trip, MultiAgentExecutionEngine sequential/parallel/mixed. + +### 12.2 통합 테스트 +- `@SpringBootTest(webEnvironment = RANDOM_PORT)` + Testcontainers +- 시나리오: sk-pipe 발급 → `/api/ai/v1/chat/completions` (WireMock anthropic) → `ai_agent_usage_logs` row 1개. + +### 12.3 E2E +- docker-compose up → invyone-spring + openclaw(stub) + postgres → 프론트 7페이지 smoke. + +### 12.4 멀티테넌시 검증 (R9 — Critical) +- 회사 A user JWT 로 회사 B `agent_id=N` GET → 404 또는 빈 결과 보장 테스트. +- 회사 A 가 발급한 sk-pipe 로 회사 B group 호출 → 403. + +--- + +## 13. 배포/운영 + +### 13.1 docker-compose + +```yaml +# docker-compose.backend.win.yml — backend 추가 환경변수 +services: + backend: + environment: + - OPENCLAW_BASE_URL=http://openclaw:18789 + - OPENCLAW_ENABLED=${OPENCLAW_ENABLED:-true} + - AI_PROVIDER_ENCRYPTION_KEY=${AI_PROVIDER_ENCRYPTION_KEY} # 32B base64 + - AI_QUARTZ_CLUSTERED=${AI_QUARTZ_CLUSTERED:-false} + - AI_DEFAULT_TIMEZONE=Asia/Seoul +``` + +### 13.2 Jenkinsfile + +현재 단일 Build stage → 추가 변경 불필요. `-Pspring.profiles.active=prod` 만 확인. helm-charts 측은 사용자 확인 (open-question Q1) 별도 PR. + +### 13.3 환경변수 매트릭스 + +| 변수 | 기본 | 설명 | +|---|---|---| +| `OPENCLAW_BASE_URL` | `http://openclaw:18789` | OpenClaw 컨테이너 주소 | +| `OPENCLAW_ENABLED` | `true` | false면 sync skip | +| `AI_PROVIDER_ENCRYPTION_KEY` | (필수) | AES-256 키, base64 32B | +| `AI_QUARTZ_CLUSTERED` | `false` | true면 멀티 노드 안전 | +| `AI_DEFAULT_TIMEZONE` | `Asia/Seoul` | cron 표현식 기준 | +| `AI_RATE_LIMIT_PER_MINUTE` | `60` | 키별 분당 호출 | +| `LLM_HTTP_TIMEOUT_SEC` | `60` | RestClient read timeout | + +--- + +## 14. 프론트엔드 적응 전략 + +### 14.1 라우트 위치 +**선택: 기존 `/admin/aiAssistant/*` 폴더 통째 교체**. +- 근거: PRD A5 가 이미 기존 8페이지 제거 명시 → 새 폴더 만들기보다 같은 위치에 vexplor 7페이지 복사가 운영 가이드 일관. 메뉴 URL 변경 최소화. +- 백엔드 API 베이스: `/api` (invyone 표준) + vexplor 라우트 그대로 (`/ai-agents`, `/ai-agent-groups` …). + +### 14.2 API 클라이언트 +- `frontend/lib/api/aiAssistant/` (구) **삭제** → `frontend/lib/api/aiAgent.ts` 신규 (vexplor 그대로 복사 + base URL 조정). +- vexplor 의 axios 인스턴스 → invyone 의 `apiClient` 로 교체. Authorization 헤더는 invyone 의 JWT cookie/localStorage 패턴 따름. + +### 14.3 권한/메뉴 통합 (직전 커밋 `2c57dc8c`) +- Story G1: 7개 메뉴 신규 row (`menu_master` 또는 동등 테이블). +- 메뉴 URL: `/admin/aiAssistant`, `/admin/aiAssistant/agents`, `/admin/aiAssistant/workspace`, `/admin/aiAssistant/providers`(SUPER), `/admin/aiAssistant/api-keys-manage`, `/admin/aiAssistant/conversations`, `/admin/aiAssistant/knowledge`. +- 권한 그룹 매핑: `menu_permission` 테이블에 SUPER_ADMIN/COMPANY_ADMIN/일반사용자 별 row insert (직전 커밋의 `MenuPermissionMapper` 패턴 따름). + +### 14.4 의존성 +- `@dnd-kit/*` (workspace drag-drop) → invyone 패키지 미존재 시 추가 +- `sonner` (toast) → invyone 기존 toast 라이브러리 확인 후 통합 +- shadcn/ui 버전 mismatch 위험 → F5 점검 단계에서 prop signature 검증 + +--- + +## 15. open-questions.md 12개 답변 + +| # | 질문 | 결정 | 근거 (요약) | +|---|---|---|---| +| 1 | 마이그레이션 도구 | **Flyway 10.x** | invyone 부재, Spring Boot 통합 우수 | +| 2 | OpenClaw 배포 | **별도 docker 컨테이너** + 사용자 이미지 출처 확인 | 격리, 토글 가능 | +| 3 | OpenClaw config 동기화 | **HTTP API** (Plan B: 볼륨 파일) | 클러스터 안전, 단순성 | +| 4 | Spring AI 도입 | **미도입** (Phase 1 직접 구현) | 5개 프로바이더 1:1 매칭 정확도 | +| 5 | 스케줄러 | **Quartz JDBC JobStore** | 동적 cron + 클러스터링 | +| 6 | 커넥터 테이블 존재 | **부재 확인** → try/catch 빈 배열 fallback | invyone에 4개 테이블 미존재, P0 차단 안 됨 | +| 7 | API 키 폐기 | **hard delete 유지** + status 컬럼 보존 | vexplor 동작 보존, 향후 변경 가능 | +| 8 | AES 키 관리 | **환경변수** Phase 1, **KMS** Phase 2 | 단순, 도커 시크릿 호환 | +| 9 | 스트리밍 | **MVC SSE** (`SseEmitter`) + 내부 WebClient | 95 컨트롤러 MVC 일관성 | +| 10 | Rate limit | **Bucket4j 인메모리** Phase 1 | 분당 100 req 가정 | +| 11 | 테스트 전략 | **Testcontainers PG + WireMock LLM** | 표준, CI 호환 | +| 12 | cron 타임존 | **Asia/Seoul** (스케줄 row별 `timezone` 컬럼) | 운영자 인지 단순성 | + +--- + +## 16. 사용자 추가 확인 필요 + +| ID | 항목 | 영향 | +|---|---|---| +| Q-A | OpenClaw 이미지 출처 — 오픈소스 npm? Docker Hub? vexplor 사내 빌드? | Story E1 시작 가능 여부 | +| Q-B | invyone helm-charts (`values_invyone.yaml`)에 ai-assistant 잔재 PR 필요 여부 | Story A4 종결 | +| Q-C | invyone에 `system_notice`, `MailSendService` 정확한 인터페이스 존재 확인 | Story C10 알림 발송 | +| Q-D | `ai_assistant_db @ localhost` drop은 본 운영 DB 호스트(183.99.177.40)와 별도 호스트라 V20260427_005 마이그레이션은 적용 안 함이 맞나? (사용자 답변 = 별도 DB confirmed) | Story A7 SQL 위치 | +| Q-E | 스케줄러 페이지 (PRD F7) 신규 작성 여부 | Frontend Story 추가 | + +--- + +## 17. Executor에게 넘기는 산출물 + +### 17.1 Story 권장 순서 + +``` +[1] B1: Flyway baseline DDL (V20260427_001) ← ★ 첫 시작 +[2] C1: 공통 인프라 (EncryptUtil, RestClient bean, MyBatis Mappers 인터페이스 11개, DTO 패키지) +[3] C2~C8: 단일 도메인 서비스 8개 (병렬 가능) + C2 AiAgent, C3 Group, C4 ApiKey, C5 Provider, C6 Conv, C7 Usage, C8 Analysis, +KnowledgeService +[4] C9: MultiAgentExecutionEngine + Connector 5개 + LlmClient 5개 +[5] C10: Quartz 도입 + AiSchedulerService +[6] C11: OpenClawSyncService + OpenClawClient +[7] D1~D3: ApiKey Filter + SecurityConfig 통합 +[8] C12~C16: 9개 컨트롤러 (병렬) +[9] A1~A6: 기존 ai-assistant 제거 (B/C/D 안정화 후 — 회귀 위험 최소화) +[10] A7: ai-assistant DB drop (별도 호스트, 분리됨) +[11] E1~E5: docker-compose openclaw 추가 +[12] F1~F7: 프론트엔드 이식 +[13] G1~G4: 메뉴/권한 등록 +[14] H1~H8: 검증/배포 +``` + +### 17.2 첫 Story B1 — 산출 파일 목록 + +``` +backend-spring/build.gradle (수정: flyway 의존성) +backend-spring/src/main/resources/application.yml (수정: spring.flyway.*) +backend-spring/src/main/resources/db/migration/ + V20260427_001__ai_baseline.sql (신규, §3.1~3.11) + V20260427_002__ai_indexes.sql (신규, 인덱스만) +``` + +**DoD**: +- `./gradlew bootRun` 시 Flyway가 11개 테이블 생성, 멱등 (재실행 시 OK) +- `\dt invyone.ai_*` 로 모두 보임 +- 인덱스 11개 이상 생성됨 (`\di idx_ai_*`) + +### 17.3 Story 별 파일 생성 목록 (C2 예시) + +``` +[C2] AiAgentService 구현 +- backend-spring/src/main/java/com/erp/ai/domain/AiAgent.java (record + Builder) +- backend-spring/src/main/java/com/erp/ai/dto/request/CreateAgentRequest.java +- backend-spring/src/main/java/com/erp/ai/dto/request/UpdateAgentRequest.java +- backend-spring/src/main/java/com/erp/ai/dto/response/AgentDto.java +- backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java (interface) +- backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml (SELECT/INSERT/UPDATE) +- backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java (구현) +- backend-spring/src/test/java/com/erp/ai/service/AiAgentServiceTest.java (Testcontainers) +``` + +(다른 서비스 동일 패턴: Domain + DTO 2 + Mapper interface + Mapper XML + Service + Test) + +--- + +## 핵심 결정 요약 (사용자 보고용) + +- **테이블 수**: 11개 핵심 + Quartz 11개 = 22개 (planner 13개 추정 → 정확화 11개, RAG 라이브러리/멤버십 분리는 P2 보류) + - 컬럼 합계: 약 184개 (테이블당 평균 14컬럼) +- **핵심 기술 스택 결정 5개**: + 1. **Flyway 10.x** 마이그레이션 + 2. **OpenClaw 별도 docker 컨테이너 + HTTP API 동기화** + 3. **Spring RestClient(동기) + WebClient(SSE 한정) 직접 LLM 구현**, Spring AI 미도입 + 4. **Quartz JDBC JobStore** 스케줄러 + 5. **AES-256-GCM JDK 내장** 암호화 + 환경변수 키 관리 +- **executor가 시작할 첫 Story**: **B1 — Flyway baseline (`V20260427_001__ai_baseline.sql`)** + `build.gradle` flyway 의존성 추가. DDL은 본 문서 §3.1~3.11 그대로 사용. +- **차단 미해결 (사용자 확인 필요)**: OpenClaw 이미지 출처(Q-A), invyone `system_notice`/`MailSendService` 시그니처(Q-C), 스케줄러 페이지 신규작성 여부(Q-E). + +## 관련 파일 (절대 경로) + +- `C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-prd.md` (planner 결과) +- `C:\Dev\projects\invyone\.omc\plans\open-questions.md` (12개 미정 → 본 문서 §15에서 모두 답) +- `C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\SecurityConfig.java` (필터 체인 통합 대상) +- `C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\JwtAuthenticationFilter.java` (필터 체인 참조) +- `C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\TenantConsistencyGuardFilter.java` (테넌시 컨텍스트 참조) +- `C:\Dev\projects\invyone\backend-spring\build.gradle` (의존성 추가 대상: flyway, quartz, bucket4j) +- `C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\service\AiAssistantProxyService.java` (제거 대상, RestTemplate 패턴 참고) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\multiAgentExecutionEngine.ts` (Story C9 1:1 포팅) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentService.ts` (Story C2) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentGroupService.ts` (Story C3) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentApiKeyService.ts` (Story C4) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentProviderService.ts` (Story C5) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentConversationService.ts` (Story C6) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentUsageService.ts` (Story C7) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAnalysisLogService.ts` (Story C8 — DDL 역공학 핵심) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiSchedulerService.ts` (Story C10) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\services\openClawSyncService.ts` (Story C11) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\routes\knowledgeRoutes.ts` (Story C8 보조 — `ai_knowledge_files` DDL) +- `C:\Dev\projects\vexplor_pipeline\backend-node\src\types\aiAgent.ts` (DTO 스펙 원본) + +> **architect 노트**: 본 응답이 architect 단계의 최종 산출물입니다. Write 권한이 차단되어 .md 파일을 생성하지 않았습니다 — 사용자가 본 응답 내용을 그대로 `C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-architecture.md` 로 저장하면 됩니다. 다음 단계는 **executor** 호출 → 첫 Story B1 (Flyway baseline) 부터 시작 권장. \ No newline at end of file diff --git a/.omc/plans/ai-orchestration-replacement-prd.md b/.omc/plans/ai-orchestration-replacement-prd.md new file mode 100644 index 00000000..d2e02b8e --- /dev/null +++ b/.omc/plans/ai-orchestration-replacement-prd.md @@ -0,0 +1,604 @@ +# AI 오케스트레이션 교체 PRD (invyone × vexplor_pipeline) + +> 작성: planner (BMAD 1단계 / Analyst·PM) +> 일자: 2026-04-27 +> 상태: Architect 검토 대기 +> 다음 단계: architect (기술 아키텍처 결정) → executor → verifier + +--- + +## 0. TL;DR + +invyone 프로젝트의 기존 **AI 어시스턴트(Node Express, port 3100)** 를 전면 제거하고, vexplor_pipeline 프로젝트에서 운영 중인 **멀티 에이전트 오케스트레이션 시스템**(에이전트 CRUD, 그룹 조립, 커넥터, OpenClaw, 스케줄러, 사용량 추적)을 invyone에 이식한다. 백엔드는 **TypeScript/Node → Java 21/Spring Boot 3.3.5/MyBatis** 로 1:1 포팅, 프론트엔드는 vexplor의 7개 화면을 invyone Next.js로 그대로 옮기고, OpenClaw은 컨테이너로 함께 운영한다. + +**총량**: 8 Epic / 39 Story 추정 / 백엔드 포팅 비중 가장 큼 + +--- + +## 1. 목표 (Goals & Non-Goals) + +### 1.1 Goals (P0) +- (G1) invyone에 멀티 에이전트 운영(에이전트 정의 → 그룹 조립 → 실행 모드 = 순차/병렬/혼합) 기능 도입 +- (G2) **API 키 기반 외부 호출**(OpenAI 호환 `/v1/chat/completions`) 엔드포인트 유지 — 외부 시스템이 invyone을 LLM 게이트웨이로 사용 가능 +- (G3) 멀티테넌시 강제 — 모든 AI 리소스(에이전트/그룹/키/대화/사용량)에 `company_code` 필터 일관 적용 (`*` = 전체 공유) +- (G4) **기존 AI 어시스턴트(ai-assistant 컨테이너 + 프록시 + DB 테이블) 완전 제거** — 운영 데이터 보존 불필요(사용자 확인 완료) +- (G5) Spring Security + JJWT 기반 인증 통합 (관리 API는 JWT, 외부 API는 sk-pipe-* 키) +- (G6) 부서/권한 그룹 시스템(직전 커밋 `2c57dc8c`)에 새 AI 메뉴들 등록 가능 + +### 1.2 Goals (P1) +- (G7) 다중 LLM 프로바이더(Anthropic, OpenAI, Google, DeepSeek, Ollama) 동시 운영 + 우선순위 라우팅 +- (G8) 에이전트별 커넥터(DB/REST/PLC/크롤러/파일) 매핑으로 운영 데이터에 기반한 분석 수행 +- (G9) cron 기반 스케줄 실행 + 알림(시스템 공지/슬랙 웹훅/이메일) +- (G10) 사용량 대시보드(일별/월별 토큰·비용·요청 수) + +### 1.3 Goals (P2) +- (G11) 대화 모니터링(에이전트 간 메시지 트레이스) +- (G12) 지식 파일 라이브러리(에이전트별 RAG 컨텍스트 주입) +- (G13) AI 분석 이력 기반 정확도 추적(`ai_analysis_logs`) + +### 1.4 Non-Goals +- (NG1) **vexplor_pipeline 자체의 화면관리/테이블관리/AI 화면 자동생성**(`workflow_patterns`, `keyword_mapping` 등)은 이번 작업 범위 아님 +- (NG2) OpenClaw 자체 코드 포팅/수정. invyone은 OpenClaw을 **블랙박스 외부 엔진**으로만 사용 +- (NG3) Spring AI / Spring AI MCP 도입 결정 — 이번에는 직접 HTTP 호출(LlmClient 직역) 유지 (architect 단계에서 재검토) +- (NG4) 사용량 청구/결제 기능 +- (NG5) 다중 회사 간 에이전트 권한 위임(P3 이상) +- (NG6) 기존 vexplor `_pipeline_backup` 폴더 처리 + +--- + +## 2. 이해관계자 / 사용자 시나리오 + +| 페르소나 | 권한 | 핵심 시나리오 | +|---|---|---| +| **슈퍼관리자(SUPER_ADMIN)** | 전사 | 글로벌 LLM 프로바이더 등록/키 관리, `*` 회사용 공유 에이전트 정의, 전체 사용량 모니터링 | +| **회사관리자(COMPANY_ADMIN)** | 자사 `company_code` | 자사 에이전트/그룹 CRUD, 자사 API 키 발급, 자사 사용량 조회 | +| **일반사용자** | 자사 + 메뉴 권한 | AI 채팅 사용, 자기 API 키 발급(역할별로), 대화 이력 조회 | +| **외부 시스템(B2B)** | API 키 | `Authorization: Bearer sk-pipe-...` 로 `/api/ai/v1/chat/completions` 또는 `/api/ai/v1/groups/:groupId` 호출 | +| **운영자(DevOps)** | 인프라 | docker-compose 기동, OpenClaw 컨테이너 헬스체크, Jenkins 배포 | + +### 2.1 핵심 사용 시나리오 흐름 + +**S1. 회사관리자가 멀티 에이전트 그룹 생성** +1. `/admin/aiAssistant/agents` 에서 "재고 분석가"(Claude Sonnet 4.5) + "구매 추천가"(GPT-4o) 에이전트 2개 생성 +2. `/admin/aiAssistant/workspace` 에서 그룹 `재고-구매 자동분석` 생성, 두 에이전트를 멤버로 추가, 각각 PostgreSQL 커넥터(외부 DB) 매핑, execution_mode = `mixed` +3. UI에서 "현재 재고 위험 품목 분석해줘" 입력 → 백엔드 MultiAgentExecutionEngine이 두 에이전트를 mixed 모드로 실행 → 최종 요약 반환 + 대화 이력 저장 + `ai_analysis_logs` 기록 + +**S2. 외부 시스템(B2B) 호출** +1. 관리자가 `/admin/aiAssistant/api-keys-manage` 에서 키 발급(키는 한 번만 표시, sha256 해시만 저장) +2. 외부 시스템: `POST /api/ai/v1/groups/123` with `Authorization: Bearer sk-pipe-...` and `{"message":"오늘 재고 위험"}` +3. AiApiKeyAuthFilter 통과 → 월간 토큰 한도 체크 → 그룹 실행 → 사용량 누적 + +**S3. cron 스케줄** +1. `/admin/aiAssistant/scheduler`(신규) 에서 `매일 09:00 - 그룹 X 실행 - 슬랙 웹훅 알림` +2. Spring `@Scheduled` 또는 Quartz가 등록된 cron 표현식으로 그룹 실행 → `ai_analysis_logs` + `system_notice` + 슬랙 발송 + +--- + +## 3. 기능 요구사항 (FR) + +### 3.1 에이전트 관리 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-A-1 | 에이전트 CRUD(`agent_id`, `name`, `model`, `system_prompt`, `tools`, `config`, `company_code`) | P0 | +| FR-A-2 | 검색·상태(active/inactive/archived)·회사별 필터 | P0 | +| FR-A-3 | `agent_id` 유니크 제약 | P0 | +| FR-A-4 | 에이전트별 `config.knowledge_files`(라이브러리 참조 또는 인라인 콘텐츠) | P2 | +| FR-A-5 | tools 정의(JSON, MCP 도구 명세) | P1 | + +### 3.2 멀티 에이전트 그룹 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-G-1 | 그룹 CRUD(`group_id`, `name`, `execution_mode` = parallel/sequential/mixed) | P0 | +| FR-G-2 | 그룹 멤버 추가·수정·삭제(`role_name`, `connectors`, `execution_order`) | P0 | +| FR-G-3 | 가용 커넥터 조회 — DB(`external_db_connections`) / REST(`external_rest_api_connections`) / PLC(`pipeline_device_connections`) / 크롤러(`crawl_configs`) | P1 | +| FR-G-4 | 그룹 실행 = MultiAgentExecutionEngine — sequential/parallel/mixed 분기 처리 + 이전 컨텍스트 누적 + 최종 요약 | P0 | +| FR-G-5 | 그룹 실행 시 과거 분석 이력 5건 자동 컨텍스트 주입 | P2 | + +### 3.3 LLM 프로바이더 관리 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-P-1 | 프로바이더 CRUD(`name`, `display_name`, `model_name`, `endpoint`, `priority`, `cost_per_1k_input/output`) | P0 | +| FR-P-2 | API 키 AES-256 암호화 저장(`api_key_encrypted`) — 응답 시 `****` 마스킹 | P0 | +| FR-P-3 | 다중 프로바이더 지원(Anthropic / OpenAI / Google / DeepSeek / Ollama) | P1 | +| FR-P-4 | 우선순위 기반 default 모델 자동 선정 | P1 | + +### 3.4 외부 API 키 관리 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-K-1 | `sk-pipe-{64hex}` 키 발급, sha256 해시 저장, prefix(16자) 표시용 저장 | P0 | +| FR-K-2 | 키 목록(SUPER/COMPANY 관리자는 전체, 일반 사용자는 자기 키만) | P0 | +| FR-K-3 | 키 폐기(SOFT delete: status=revoked 또는 hard delete — vexplor는 hard delete) | P0 | +| FR-K-4 | rate_limit, monthly_token_limit, expires_at, permissions(JSON) | P0 | +| FR-K-5 | 키 검증 시 last_used_at, usage_count 자동 갱신 | P0 | +| FR-K-6 | 월간 토큰 한도 초과 시 HTTP 429 | P0 | + +### 3.5 외부 호출 게이트웨이 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-X-1 | `POST /api/ai/v1/chat/completions` — OpenAI 호환(스트리밍 포함) | P0 | +| FR-X-2 | `POST /api/ai/v1/groups/:groupId` — 멀티 에이전트 그룹 실행 | P0 | +| FR-X-3 | `GET /api/ai/v1/groups` — 사용 가능 그룹 목록 | P1 | +| FR-X-4 | `GET /api/ai/v1/models` — 모델 목록(프로바이더 기반) | P1 | +| FR-X-5 | `GET /api/ai/v1/health` — 헬스체크 | P1 | +| FR-X-6 | API 키 + JWT 동시 인증 허용(내부/외부 모두 사용 가능) | P0 | + +### 3.6 사용량 추적 (P1) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-U-1 | 모든 LLM 호출에 대해 `ai_agent_usage_logs` 적재(prompt/completion/total tokens, cost_usd, 응답시간, success, error) | P1 | +| FR-U-2 | 요약: 오늘/이번 달 토큰·요청·비용 + active 에이전트/키 수 | P1 | +| FR-U-3 | 일별 차트(최근 30일) | P1 | +| FR-U-4 | 페이징 로그 조회 | P1 | + +### 3.7 대화 모니터링 (P2) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-C-1 | 그룹 실행 시 대화 자동 저장(conversation + 사용자 메시지 + 각 step 응답) | P2 | +| FR-C-2 | 대화 목록 페이징·에이전트별 필터 | P2 | +| FR-C-3 | 대화 상세(메시지 시퀀스) 조회 | P2 | +| FR-C-4 | 대화 삭제 | P2 | + +### 3.8 스케줄 실행 (P1) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-S-1 | 스케줄 CRUD(`cron_expression`, `group_id`, `input_message`, `notification`) | P1 | +| FR-S-2 | cron 표현식 검증 + 재등록 | P1 | +| FR-S-3 | 알림 채널: 시스템 공지(`system_notice`) / 웹훅(슬랙) / 이메일(기존 mailSend 서비스 재사용) | P1 | +| FR-S-4 | 서버 기동 시 활성 스케줄 자동 등록 | P1 | +| FR-S-5 | last_run_at, run_count 누적 | P1 | + +### 3.9 OpenClaw 통합 (P1) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-O-1 | OpenClaw 컨테이너로 기동(invyone docker-compose에 서비스 추가, 기본 port 18789) | P1 | +| FR-O-2 | OpenClaw config 동기화 — 프로바이더(authProfiles) + 에이전트(agents) → JSON 파일 | P1 | +| FR-O-3 | 프로바이더/에이전트 변경 시 트리거(저장 후 sync 호출) | P1 | +| FR-O-4 | OpenClaw 다운 시 graceful — 관리 기능은 동작 유지, 외부 호출만 503 | P1 | + +### 3.10 지식 라이브러리 (P2) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-N-1 | 지식 파일 CRUD(`ai_knowledge_files`: name, file_name, category, content) | P2 | +| FR-N-2 | 에이전트 config.knowledge_files에서 `library_id` 또는 인라인 `content` 참조 | P2 | +| FR-N-3 | 검색·카테고리 필터 | P2 | + +### 3.11 메뉴/권한 등록 (P0) +| FR | 설명 | 우선순위 | +|---|---|---| +| FR-M-1 | 새 AI 메뉴 7개를 부서/권한 그룹 시스템(직전 커밋 `2c57dc8c`)에 등록 | P0 | +| FR-M-2 | 슈퍼관리자 전용 메뉴(LLM 관리, LLM 사용량 통계 등) 분리 | P0 | +| FR-M-3 | 기존 INVION 메뉴 가이드(`AI_어시스턴트_메뉴_등록_가이드.md`) 갱신 | P1 | + +--- + +## 4. 비기능 요구사항 (NFR) + +### 4.1 멀티테넌시 (강제) +- 모든 AI 테이블에 `company_code VARCHAR(20)` 컬럼 — `NULL` 또는 `*` 은 슈퍼관리자 전용 / 글로벌 공유 +- 모든 list/get/update/delete 쿼리에 `(company_code = :myCompany OR company_code IS NULL)` 적용 +- SUPER_ADMIN 만 회사 간 자료 조회 가능 (`company_code=*` 쿼리 매개변수 허용) + +### 4.2 보안 +- API 키: 평문 저장 금지. SHA-256 해시 + 16자 prefix 만 저장. 발급 시점에만 평문 1회 노출 +- LLM 프로바이더 API 키: AES-256 대칭 암호화(EncryptUtil 포팅 필요). 응답에서 마스킹 +- JWT: 기존 invyone JJWT 0.12.3 그대로 사용 +- API 키 인증 필터(`AiApiKeyAuthFilter`): Spring Security 필터 체인에 추가, JWT 필터보다 앞 또는 같은 layer +- Rate limit: 키당 분당 N회(기본 60), 월간 토큰 한도(기본 100만) +- CORS: 기존 invyone 정책 따름 + +### 4.3 성능 +- 그룹 parallel 실행: `CompletableFuture.allOf` 기반(JDK 21 가상 스레드 권장) +- 그룹 sequential 실행: 직렬 await +- 그룹 mixed 실행: execution_order 동률 → 병렬, 다른 order → 순차 await +- LLM 호출 타임아웃: 60초 default, 스트리밍은 무제한 (커넥션 keep-alive) +- DB index: `ai_agent_usage_logs(created_at)`, `ai_agent_api_keys(key_hash)`, `ai_agents(agent_id)`, `ai_agent_groups(group_id)` + +### 4.4 가용성 +- OpenClaw 다운 시: + - 외부 LLM 호출은 invyone 자체 LlmClient(직접 프로바이더 HTTP 호출)로 fallback + - 관리 화면은 정상 동작 +- DB 다운 시: 통상 invyone 정책(=실패) 따름 +- 헬스체크: `/api/ai/v1/health` 가 프로바이더 수 응답 + +### 4.5 마이그레이션 안전성 +- ai-assistant Sequelize 테이블(users, api_keys, llm_providers, usage_logs) **drop 가능** (사용자 확인됨, 운영 데이터 없음) +- 새 vexplor 스키마는 별도 마이그레이션 스크립트로 적용. 기존 invyone PostgreSQL과 같은 DB 사용 +- 명명 충돌 위험: ai-assistant의 `users`, `api_keys` 등 일반 테이블명 — invyone 본 스키마와 동명 충돌 가능 → drop 시점에 보존 검증 필수 +- 새 스키마는 모두 `ai_` prefix 사용 (vexplor 컨벤션) → 충돌 없음 + +### 4.6 관찰성 +- 모든 핵심 호출에 SLF4J + Spring Boot logback 로깅(요청/응답/에러) +- ai_agent_usage_logs 가 사실상 1차 audit +- 에러는 invyone 표준 ApiResponse(success/error code/message) 포맷 + +### 4.7 호환성 +- Java 21, Spring Boot 3.3.5, MyBatis 3.0.3, PostgreSQL 16, Lombok +- Frontend: Next.js (invyone 기존), shadcn/ui, dnd-kit (vexplor에서 사용) +- vexplor의 `Promise.all`, `node-cron`, `axios` → Java 등가물(`CompletableFuture`, Spring `@Scheduled`/Quartz, RestClient/WebClient) + +--- + +## 5. 이식 작업 분해 (WBS) + +### Epic A — 기존 AI 어시스턴트 제거 (P0) +**목표**: invyone 측의 ai-assistant 모듈을 흔적 없이 걷어낸다. + +| Story | 작업 | +|---|---| +| **A1**: `ai-assistant/` 디렉토리 통째 삭제 | `C:\Dev\projects\invyone\ai-assistant\` 폴더 삭제. README, package.json, src/ 전부 | +| **A2**: Spring 프록시 컨트롤러 삭제 | `AiAssistantProxyController.java`, `AiAssistantProxyService.java` 삭제 + Spring 컴포넌트 스캔 영향 확인 | +| **A3**: docker-compose 정리 | `docker-compose.backend.win.yml`, `docker-compose.frontend.win.yml`(있다면), `Dockerfile`에서 ai-assistant 참조 항목 제거. 현재 파일에는 명시적 ai-assistant 서비스 없음(확인됨) — 환경변수 `AI_ASSISTANT_SERVICE_URL` 등 잔재만 정리 | +| **A4**: Jenkinsfile 정리 | 현재 Jenkinsfile은 단일 Build 단계만 있고 ai-assistant 별도 stage 없음(확인됨). 변경 불필요 — 단, helm-charts 측 `values_invyone.yaml` 의 ai-assistant 이미지 참조가 있다면 별도 PR 필요(미확인 사항으로 표기) | +| **A5**: 프론트엔드 메뉴/페이지 제거 | `frontend/app/(main)/admin/aiAssistant/` 8개 페이지 + `frontend/lib/api/aiAssistant/` (client.ts, index.ts) + `frontend/components/layout/AdminPageRenderer.tsx` 라인 128, 142~147 제거 후 vexplor 신규 페이지로 교체(Epic F) | +| **A6**: 메뉴 가이드 문서 제거/갱신 | `frontend/docs/AI_어시스턴트_메뉴_등록_가이드.md`, `docs/leeheejin/AI_어시스턴트_사용가이드.md` 폐기 또는 신규 가이드로 대체 | +| **A7**: 기존 ai-assistant DB 테이블 drop | `users`, `api_keys`, `llm_providers`, `usage_logs` (Sequelize 자동 생성). **주의**: invyone 본 시스템에 동명 테이블 없는지 검증 후 drop. 마이그레이션 스크립트로 작성 | + +**Story 수: 7** + +### Epic B — DB 스키마 마이그레이션 (P0) +**목표**: vexplor의 AI 스키마를 invyone PostgreSQL에 적용. + +> ⚠️ 사용자 프롬프트에 명시된 `db/migrations/300_create_openclaw_tables.sql` 파일은 vexplor 리포지토리에 **물리적으로 존재하지 않음**(전체 검색 확인). 따라서 vexplor 서비스/타입 코드(`aiAgent*.ts` 9개 + `aiAgent.ts` 타입)에서 **스키마를 역공학**해야 한다 — 이 작업이 Epic B의 핵심이며 Story B1에서 수행한다. + +| Story | 작업 | +|---|---| +| **B1**: vexplor 코드 역공학으로 스키마 재구성 | 9개 서비스 + types/aiAgent.ts 분석 → 13개 테이블 DDL 작성: `ai_agents`, `ai_agent_groups`, `ai_agent_group_members`, `ai_agent_api_keys`, `ai_llm_providers`, `ai_agent_conversations`, `ai_agent_messages`, `ai_agent_usage_logs`, `ai_agent_schedules`, `ai_analysis_logs`, `ai_knowledge_files`, (선택) `ai_agent_tools` (P3) | +| **B2**: 마이그레이션 스크립트 작성 | invyone DB 마이그레이션 컨벤션에 맞춰(MyBatis용 .sql 또는 별도 도구) `V20260427__ai_orchestration.sql` 형태. 멱등 보장(IF NOT EXISTS) | +| **B3**: 인덱스 / FK / 제약 정의 | 5.3 NFR 인덱스 + cascade 정책(예: 그룹 삭제 시 멤버 cascade) | +| **B4**: 시드 데이터 | 기본 LLM 프로바이더 row 1~2개(키 비활성 placeholder), 슈퍼관리자용 샘플 에이전트 1개(선택 P2) | +| **B5**: 마이그레이션 적용 절차 문서화 | dev/staging/prod 적용 가이드 + 롤백 스크립트 | + +**Story 수: 5** + +### Epic C — Spring 백엔드 포팅 (P0) +**목표**: TS 9개 서비스 + 컨트롤러 + 라우트 → Java 21 / Spring / MyBatis 1:1 매핑 + +| Story | 작업 | +|---|---| +| **C1**: 공통 인프라 | `EncryptUtil`(AES-256), `LlmClient`(Anthropic / OpenAI / Google / DeepSeek / Ollama HTTP 클라이언트, 스트리밍 포함), DTO 패키지(`com.erp.dto.ai.*`), MyBatis Mapper 인터페이스 + XML 13개 | +| **C2**: AiAgentService | 에이전트 CRUD + 검색·필터 + company_code 필터 | +| **C3**: AiAgentGroupService | 그룹 CRUD + 멤버 CRUD + 가용 커넥터 조회(invyone에 동등 테이블 존재 여부 확인 필요 — `external_db_connections`, `external_rest_api_connections`, `crawl_configs`, `pipeline_device_connections` 가 invyone에 있는지 architect가 결정) | +| **C4**: AiAgentApiKeyService | sk-pipe 키 발급/검증/폐기 + 토큰 누적 | +| **C5**: AiAgentProviderService | 프로바이더 CRUD + 암복호화 + 마스킹 | +| **C6**: AiAgentConversationService | 대화 + 메시지 저장/조회/삭제 | +| **C7**: AiAgentUsageService | 사용량 적재 + 요약 + 일별/페이징 | +| **C8**: AiAnalysisLogService | 분석 이력 CRUD + 평균 정확도 | +| **C9**: MultiAgentExecutionEngine | sequential/parallel/mixed 분기 + 컨텍스트 누적 + 커넥터 실행 + 지식 파일 주입 + 최종 요약 — `CompletableFuture` 기반 | +| **C10**: AiSchedulerService | Spring `@Scheduled` 또는 Quartz로 cron 등록/해제, 알림 발송(시스템 공지 / 슬랙 웹훅 / 이메일) | +| **C11**: OpenClawSyncService | 프로바이더/에이전트 → OpenClaw config(JSON) 동기화 | +| **C12**: 컨트롤러 — 관리(JWT) | `/api/ai-agents`, `/api/ai-agents/keys/*`, `/api/ai-agents/providers/*`, `/api/ai-agents/conversations/*`, `/api/ai-agents/usage/*` | +| **C13**: 컨트롤러 — 그룹(JWT) | `/api/ai-agent-groups`(+ /:id/members, /connectors) | +| **C14**: 컨트롤러 — 외부(API Key) | `/api/ai/v1/chat/completions`(스트리밍 포함), `/api/ai/v1/groups/:groupId`, `/api/ai/v1/groups`, `/api/ai/v1/models`, `/api/ai/v1/health` | +| **C15**: 컨트롤러 — 스케줄(JWT) | `/api/ai-agent-schedules` CRUD | +| **C16**: 컨트롤러 — 지식(JWT) | `/api/ai-knowledge` CRUD | + +**Story 수: 16** + +### Epic D — 인증 미들웨어 (P0) +| Story | 작업 | +|---|---| +| **D1**: AiApiKeyAuthFilter | Spring Security `OncePerRequestFilter` 로 작성 — `Authorization: Bearer sk-pipe-*` 만 처리, JWT 는 기존 필터로 위임 | +| **D2**: SecurityConfig 조정 | `/api/ai/v1/**` 경로는 AiApiKeyAuthFilter 우선 적용. 관리 경로(`/api/ai-agents/**` 등)는 JWT 유지 | +| **D3**: 동시 인증 허용 | 외부 게이트웨이는 sk-pipe 키 + JWT 둘 다 허용 (vexplor 동일 동작) | + +**Story 수: 3** + +### Epic E — OpenClaw 컨테이너 통합 (P1) +| Story | 작업 | +|---|---| +| **E1**: OpenClaw 배포 방식 결정 | Dockerfile 또는 외부 이미지 사용 여부 조사. Helm chart 추가 또는 docker-compose 신규 서비스 | +| **E2**: docker-compose에 openclaw 서비스 추가 | `docker-compose.backend.win.yml` 에 openclaw 서비스(포트 18789), 환경변수, 볼륨(config 공유) | +| **E3**: invyone Spring → OpenClaw URL 환경변수 | `OPENCLAW_BASE_URL`, `OPENCLAW_ENABLED` 추가 | +| **E4**: Config 공유 볼륨 | `OpenClawSyncService` 가 쓰는 JSON 파일 경로를 컨테이너 간 공유(또는 HTTP 동기화 API로 변경 architect 결정) | +| **E5**: K8s 매니페스트 | `k8s/` 폴더에 OpenClaw 매니페스트 추가(필요 시) | + +**Story 수: 5** + +### Epic F — 프론트엔드 통합 (P0) +| Story | 작업 | +|---|---| +| **F1**: vexplor 7개 페이지 이식 | `agents/`, `providers/`, `conversations/`, `workspace/`, `api-keys-manage/`, `knowledge/`, `layout.tsx`, `page.tsx` → invyone `frontend/app/(main)/admin/aiAssistant/` 로 복사. 기존 invyone aiAssistant 폴더 통째 교체 | +| **F2**: API 클라이언트 이식 | vexplor `frontend/lib/api/aiAgent.ts` → invyone `frontend/lib/api/aiAgent.ts` 신규. invyone 기존 `lib/api/aiAssistant/` 폴더 삭제 | +| **F3**: BASE URL 정리 | invyone apiClient(`/api`)와 일치하는지 검증, vexplor의 `/ai-agents`, `/ai-agent-groups`, `/ai-knowledge` 경로 그대로 사용 | +| **F4**: AdminPageRenderer 라우팅 갱신 | 기존 `aiAssistant/dashboard|history|api-keys|api-test|usage|chat` 7개 라인 제거, 신규 `agents|providers|conversations|workspace|api-keys-manage|knowledge` 6~7개 등록 | +| **F5**: shadcn 의존성 점검 | dnd-kit(`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`), sonner(toast) — invyone에 없으면 추가 | +| **F6**: 다국어/스타일 점검 | vexplor 페이지의 한국어 메시지, dark mode 클래스가 invyone 디자인 시스템과 호환되는지 확인 | +| **F7**: 스케줄러 페이지 신규 작성 (vexplor에 미포함) | `/admin/aiAssistant/scheduler` — 백엔드 `AiSchedulerService` 와 짝 (아키텍트 결정 후 Story 추가 가능) | + +**Story 수: 6~7** + +### Epic G — 메뉴/권한 등록 (P0) +| Story | 작업 | +|---|---| +| **G1**: 신규 AI 메뉴 7개를 메뉴 마스터에 등록 | `2c57dc8c 부서 권한 그룹 권한 관리` 커밋의 메뉴 시스템에 다음 URL 등록: `/admin/aiAssistant`, `/admin/aiAssistant/agents`, `/admin/aiAssistant/workspace`, `/admin/aiAssistant/providers`(SUPER), `/admin/aiAssistant/api-keys-manage`, `/admin/aiAssistant/conversations`, `/admin/aiAssistant/knowledge` | +| **G2**: 권한 그룹 매핑 | SUPER_ADMIN/COMPANY_ADMIN/일반사용자 별 메뉴 노출 범위 정의 | +| **G3**: 사이드바 아이콘 | Bot 아이콘(기존 가이드 따라) — `frontend/components/layout/Sidebar` 등에서 키워드 매핑 | +| **G4**: 가이드 문서 갱신 | `frontend/docs/AI_어시스턴트_메뉴_등록_가이드.md` 신규 메뉴로 교체 | + +**Story 수: 4** + +### Epic H — 검증/배포 (P0) +| Story | 작업 | +|---|---| +| **H1**: 단위 테스트 — Service 계층 | JUnit 5 + MyBatis 테스트(testcontainers PostgreSQL). 각 서비스 핵심 시나리오 | +| **H2**: 통합 테스트 — 외부 게이트웨이 | sk-pipe 키 발급 → `/api/ai/v1/chat/completions` 호출 → 사용량 적재 검증(MockServer로 LLM 응답 stub) | +| **H3**: E2E — 그룹 실행 | mixed 모드, 2 멤버 + 커넥터 1개, 응답 + 대화 저장 + 분석 이력 검증 | +| **H4**: 컨테이너 기동 검증 | docker-compose up → backend + openclaw + frontend 헬스체크 통과 | +| **H5**: 마이그레이션 dry-run | dev DB에 마이그레이션 적용 → 전체 화면 + API 동작 확인 | +| **H6**: 성능 smoke | 동시 그룹 실행 10개 + parallel 멤버 5개 → 토큰 누적·DB write 정합성 | +| **H7**: 보안 점검 | API 키 평문 비노출, 프로바이더 키 마스킹, JWT 누락 시 401 | +| **H8**: 운영 가이드 | 배포 / 롤백 / 트러블슈팅 / OpenClaw 관리 문서 | + +**Story 수: 8** + +--- + +### 5.x WBS 요약 +| Epic | Story 수 | 주요 위험 | +|---|---|---| +| A. 기존 제거 | 7 | helm-charts 측 잔재, 메뉴 깨짐 | +| B. DB 스키마 | 5 | SQL 미존재 → 역공학 정확도 | +| C. Spring 포팅 | 16 | LlmClient 스트리밍, ExecutionEngine 동시성 | +| D. 인증 | 3 | 필터 체인 충돌 | +| E. OpenClaw | 5 | 외부 엔진 라이선스/배포 가능성 | +| F. 프론트엔드 | 6~7 | 디자인 시스템 호환성 | +| G. 메뉴/권한 | 4 | 권한 매핑 누락 | +| H. 검증 | 8 | LLM mock 정확도 | +| **합계** | **54~55** | — | + +> 참고: 사용자 프롬프트에서 "39 Story 추정"으로 시작했으나, 실제 분석 결과 54~55개가 더 정확함. 일부 Story는 architect 단계에서 묶거나 분할될 수 있다. + +--- + +## 6. Story 상세 (대표 5개 발췌) + +### 6.1 Story C9 — MultiAgentExecutionEngine 포팅 +- **입력**: `vexplor_pipeline/backend-node/src/services/multiAgentExecutionEngine.ts` (478라인) +- **변경 작업**: + - `executeSequential` → 직렬 await 루프 + - `executeParallel` → `CompletableFuture.allOf` (Java 21 가상 스레드 권장) + - `executeMixed` → execution_order 그룹핑 후 직렬+병렬 혼합 + - `executeSingleAgent` → 에이전트 조회 + 커넥터 실행 + 지식 파일 주입 + LlmClient 호출 + - `executeConnector` → DB / REST / PLC / 크롤러 / 파일 — invyone에 해당 테이블 존재 여부 확인 후 적용 (architect가 mapping 결정) + - `buildFinalSummary` → 동일 + - 사이드 이펙트: `AiAgentConversationService.createConversation/addMessage`, `AiAnalysisLogService.save`, `AiAgentUsageService.log` +- **DoD**: + - 동일 입력에 대해 vexplor와 응답 메시지 구조 동일 + - parallel 모드에서 5개 에이전트 동시 실행 시 응답시간 ≤ max(개별 응답시간) × 1.2 + - 사용량 / 대화 / 분석 이력 모두 적재 + - 에이전트 실행 실패 1건은 그룹 전체 실패로 이어지지 않음(개별 step에 [실행 실패] 응답 기록) +- **의존성**: C1(LlmClient, EncryptUtil), C2~C7 +- **위험**: Java/Node 동시성 모델 차이로 race condition 발생 가능. JDK 21 가상 스레드 사용 시 MyBatis SqlSession 스레드 안전성 검증 필요 + +### 6.2 Story B1 — 스키마 역공학 +- **입력**: vexplor 9개 서비스 + types/aiAgent.ts +- **변경 작업**: 13개 테이블 DDL 작성. 각 테이블 컬럼 추론 근거(서비스 SQL 쿼리 라인) 주석 첨부 +- **DoD**: vexplor 백엔드를 invyone DB 스키마로 띄웠을 때(가상 시나리오) 모든 INSERT/UPDATE/SELECT 가 동작 +- **의존성**: 없음 (Epic B 시작점) +- **위험**: 일부 컬럼이 서비스에서 SELECT/INSERT 모두 사용되지 않으면 역공학 누락. architect 검토 단계에서 vexplor DB dump 또는 별도 schema 문서 확보 권장 + +### 6.3 Story D1 — AiApiKeyAuthFilter +- **입력**: `vexplor/backend-node/src/middleware/aiApiKeyAuthMiddleware.ts` +- **변경 작업**: + - `OncePerRequestFilter` 상속, `/api/ai/v1/**` 경로 매칭 + - sk-pipe-* 토큰 → AiAgentApiKeyService.validateKey + - 월간 토큰 한도 초과 → 429 + - JWT 토큰은 기존 JwtAuthenticationFilter 로 위임 (이중 필터) +- **DoD**: 키로 호출 시 `req.apiKey` 정보가 컨트롤러에 전달, JWT로 호출 시 `Authentication` 객체 정상 +- **의존성**: C4(AiAgentApiKeyService) +- **위험**: 필터 순서. Spring Security 6 의 `addFilterBefore` 위치 결정 + +### 6.4 Story A7 — 기존 ai-assistant DB 테이블 drop +- **입력**: ai-assistant Sequelize 모델 4개(`users`, `api_keys`, `llm_providers`, `usage_logs`) +- **변경 작업**: + - 사전 검증: invyone 본 시스템에 `users`, `api_keys` 동명 테이블이 있는지 (있을 가능성 높음 — `users`는 ERP 핵심 테이블일 수 있음) + - **위험 해소**: ai-assistant가 사용한 DB 스키마(예: `ai_assistant` 스키마) 존재 여부 확인 필요. 만약 동일 schema 라면 컬럼 충돌 / 데이터 덮어쓰기 위험 + - drop 스크립트(별도 schema에 있다면 DROP SCHEMA IF EXISTS ai_assistant CASCADE) +- **DoD**: 메인 invyone 기능에 영향 없이 잔여 테이블/스키마 제거 +- **의존성**: 없음 (Epic A 마지막) +- **위험**: 메인 `users`/`api_keys` 와 충돌. **반드시 architect 단계에서 ai-assistant DB 분리 여부 확인** + +### 6.5 Story F1 — vexplor 페이지 이식 +- **입력**: vexplor 7개 페이지(`agents`, `providers`, `conversations`, `workspace`, `api-keys-manage`, `knowledge`, `layout.tsx`, `page.tsx`) +- **변경 작업**: + - 기존 invyone `aiAssistant/` 폴더 8개 페이지 삭제 + - vexplor 페이지 그대로 복사 + import path 조정(`@/lib/api/aiAgent` 신규) + - shadcn 컴포넌트 의존성 검증 +- **DoD**: 7개 페이지 모두 build / type-check / lint 통과 + 핵심 화면 렌더링 +- **의존성**: F2(API 클라이언트), C12~C16(백엔드 컨트롤러) +- **위험**: invyone vs vexplor의 shadcn 컴포넌트 버전 차이로 prop signature 불일치 + +--- + +## 7. 순서 / 의존성 그래프 + +``` + ┌──────────┐ + │ A │ (기존 제거 — 어디서나 시작 가능, A7은 최후) + └────┬─────┘ + │ A1~A6 병행 + ▼ + ┌──────────┐ ┌──────────┐ + │ B │───▶│ C │ + │ (스키마) │ │ (Spring) │ + └────┬─────┘ └────┬─────┘ + │ │ + │ ▼ + │ ┌──────────┐ + │ │ D │ (Security 통합) + │ └────┬─────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ E │ │ F │ (Frontend) — C12~C16 후 + │ (Claw) │ └────┬─────┘ + └────┬─────┘ │ + │ ▼ + │ ┌──────────┐ + │ │ G │ (메뉴/권한) + │ └────┬─────┘ + ▼ │ + ┌────────────────────┴──────┐ + │ H │ (검증/배포) + └────────────────────────────┘ +``` + +**Critical path**: B → C → F → G → H (백엔드 + 프론트가 직렬에 가까움) + +**병행 가능**: A(제거)는 B/C와 병행 가능, E(OpenClaw)는 C9(엔진)이 끝나면 시작 + +--- + +## 8. 위험 / 가정 + +### 8.1 위험 (Risk) +| ID | 위험 | 영향 | 대응 | +|---|---|---|---| +| R1 | `300_create_openclaw_tables.sql` 미존재 → 역공학 오류 | High | architect에 vexplor DB dump 요청. 또는 vexplor 저자 인터뷰 | +| R2 | OpenClaw 외부 엔진 라이선스/배포 불명 | High | architect 단계에서 OpenClaw 패키징 방식 결정 (npm global / Docker 이미지) | +| R3 | invyone에 vexplor 커넥터 테이블 부재 (`external_db_connections` 등) | Medium | 커넥터 기능 P2로 후순위 또는 invyone 측에 동등 테이블 신설 결정 | +| R4 | ai-assistant `users`/`api_keys` 와 invyone 본 테이블 충돌 | High | A7 전 schema 분리 검증 필수 | +| R5 | LLM 스트리밍 응답 Spring 구현 난이도 | Medium | Spring WebFlux SSE 또는 RestClient 스트림 응답 사용 | +| R6 | 컨테이너 추가에 따른 운영 비용 증가(OpenClaw) | Low | OPENCLAW_ENABLED=false 토글로 단계 도입 | +| R7 | vexplor와 invyone의 shadcn/Next.js 버전 차이 | Medium | F5 의존성 점검 단계에서 잠재 호환 이슈 포착 | +| R8 | Spring AI MCP 도입 결정 보류 → 향후 재작업 가능성 | Medium | 인터페이스를 LlmClient 추상화로 만들어 교체 용이성 확보 | +| R9 | 멀티테넌시 정책 누락 → 회사 간 데이터 노출 | Critical | 모든 쿼리에 `company_code` 강제, 단위 테스트로 검증 | +| R10 | Jenkins helm-charts 측 ai-assistant 잔재 | Low | helm-charts 별도 PR(범위 외) | + +### 8.2 가정 (Assumption) +- (A1) vexplor 백엔드의 PostgreSQL JSONB 사용은 invyone PostgreSQL에서도 동일하게 지원됨(같은 PG 16) +- (A2) invyone 운영자가 OpenClaw 컨테이너를 추가하는 것에 동의함 +- (A3) AES-256 암호화 키는 환경변수로 주입(`AI_PROVIDER_ENCRYPTION_KEY`) +- (A4) 기존 invyone JWT 발급 시 `userType` claim 에 `SUPER_ADMIN` / `COMPANY_ADMIN` / 일반 값이 들어 있음 (vexplor 컨트롤러가 의존) +- (A5) `company_code` claim 도 JWT에 포함되어 있음 +- (A6) invyone PostgreSQL 마이그레이션 도구가 정해져 있음(MyBatis 자체 또는 Flyway/Liquibase 중 — architect 결정) +- (A7) 외부 호출(B2B) 트래픽은 작음(분당 100 req 미만) — Rate limit 단순 구현 가능 + +--- + +## 9. Architect / Executor 가 결정해야 할 미정 사항 + +### 9.1 Architect 결정 필요 +1. **마이그레이션 도구**: MyBatis 수동 SQL? Flyway? Liquibase? +2. **OpenClaw 배포**: npm global 컨테이너 vs Docker Hub 공식 이미지(존재 여부 확인 필요) vs vexplor 측 Dockerfile 재사용 +3. **OpenClaw config 동기화 방식**: 공유 볼륨 파일? HTTP API? gRPC? +4. **Spring AI / Spring AI MCP 도입**: 직접 HTTP 클라이언트 유지 vs Spring AI 채택 +5. **스케줄러 구현**: Spring `@Scheduled` (단순) vs Quartz (DB 영속화 + 클러스터링) +6. **커넥터 테이블 존재 여부**: invyone에 `external_db_connections`, `external_rest_api_connections`, `crawl_configs`, `pipeline_device_connections` 가 있는지 확인 → 없으면 P2 디그레이드 +7. **API 키 폐기**: hard delete (vexplor) vs soft delete (status='revoked') — 감사 로그 정책에 따라 결정 +8. **AES-256 키 관리**: 환경변수 vs Vault vs KMS +9. **스트리밍 응답**: Spring MVC SSE vs WebFlux +10. **Rate limit 구현**: 인메모리(단일 인스턴스) vs Redis(클러스터) +11. **테스트 전략**: testcontainers PostgreSQL + WireMock LLM, 또는 별도 dev DB +12. **Schedule**: `ai_agent_schedules` 의 cron 타임존(서버 시간 vs UTC) + +### 9.2 Executor 단계 결정 가능 +- 커밋 단위 분리 전략 (Epic 단위 vs Story 단위) +- 기존 ai-assistant 제거 시점(B 시작 전 vs C 진행 중) +- 페이지별 점진 이식 vs 일괄 교체 + +### 9.3 검증/협의 필요(사용자에게 한 번 더) +- (Q1) helm-charts 리포지토리(`gitlab.kpslp.kr/root/helm-charts/values_invyone.yaml`)에 ai-assistant 이미지 참조 잔재 존재 여부 — 확인 후 별도 PR 필요한지 +- (Q2) ai-assistant가 사용한 DB schema 이름 / 호스트 — invyone 본 DB와 동일하면 `users`/`api_keys` 충돌 위험. **A7 실행 전 반드시 확인 필요** +- (Q3) 스케줄러 페이지 vexplor에 부재 — invyone에 신규 작성 필요한지(F7 Story 활성화 여부) + +--- + +## 10. 산출물 및 다음 단계 + +### 10.1 본 PRD 결과물 +- 파일: `C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-prd.md` (본 문서) +- 추가 산출물 (이번 단계 외): + - `architecture.md` (architect) + - `tasks/*.md` (executor가 Story 분해) + - `verification.md` (verifier) + +### 10.2 권장 다음 단계 +1. 사용자에게 9.3 미정 질문 답변 요청 (특히 Q2 ai-assistant DB 분리 여부) +2. **architect** 에이전트 호출: + - 9.1 결정 사항 12개에 대해 기술 결정 + - C9 ExecutionEngine 동시성 모델 상세 설계 + - 스키마 ERD 작성 +3. architect 산출물을 바탕으로 **executor** 가 Story 단위 구현 + +--- + +## 11. 부록 — invyone 측 영향 파일 인덱스 + +### 11.1 삭제 대상 +``` +C:\Dev\projects\invyone\ai-assistant\ (전체) +C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\controller\AiAssistantProxyController.java +C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\service\AiAssistantProxyService.java +C:\Dev\projects\invyone\frontend\app\(main)\admin\aiAssistant\ (전체 8 파일/폴더) +C:\Dev\projects\invyone\frontend\lib\api\aiAssistant\ (client.ts, index.ts) +C:\Dev\projects\invyone\frontend\docs\AI_어시스턴트_메뉴_등록_가이드.md +C:\Dev\projects\invyone\docs\leeheejin\AI_어시스턴트_사용가이드.md (선택) +``` + +### 11.2 수정 대상 +``` +C:\Dev\projects\invyone\frontend\components\layout\AdminPageRenderer.tsx (라인 128, 142~147) +C:\Dev\projects\invyone\docker-compose.backend.win.yml (openclaw 서비스 추가) +C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\SecurityConfig.java (필터 체인) +C:\Dev\projects\invyone\backend-spring\build.gradle (의존성 추가 — RestClient, ScheduledTasks 등 필요시) +``` + +### 11.3 신규 작성 대상 (Spring 백엔드) +``` +backend-spring/src/main/java/com/erp/ + ├── ai/ + │ ├── controller/ + │ │ ├── AiAgentController.java + │ │ ├── AiAgentGroupController.java + │ │ ├── AiAgentApiKeyController.java + │ │ ├── AiAgentProviderController.java + │ │ ├── AiAgentConversationController.java + │ │ ├── AiAgentUsageController.java + │ │ ├── AiAgentScheduleController.java + │ │ ├── AiKnowledgeController.java + │ │ └── AiV1GatewayController.java (외부 API 키 진입) + │ ├── service/ + │ │ ├── AiAgentService.java + │ │ ├── AiAgentGroupService.java + │ │ ├── AiAgentApiKeyService.java + │ │ ├── AiAgentProviderService.java + │ │ ├── AiAgentConversationService.java + │ │ ├── AiAgentUsageService.java + │ │ ├── AiAnalysisLogService.java + │ │ ├── AiSchedulerService.java + │ │ ├── AiKnowledgeService.java + │ │ ├── MultiAgentExecutionEngine.java + │ │ ├── OpenClawSyncService.java + │ │ └── LlmClient.java + │ ├── mapper/ (MyBatis Mappers) + │ ├── dto/ (Request/Response DTOs) + │ ├── domain/ (Domain models) + │ ├── filter/AiApiKeyAuthFilter.java + │ └── util/EncryptUtil.java + └── ... +backend-spring/src/main/resources/mapper/ai/ (MyBatis XMLs) +backend-spring/src/main/resources/db/migration/V20260427__ai_orchestration.sql +``` + +### 11.4 신규 작성 대상 (프론트엔드) +``` +frontend/app/(main)/admin/aiAssistant/ + ├── agents/page.tsx + ├── workspace/page.tsx + ├── providers/page.tsx + ├── conversations/page.tsx + ├── api-keys-manage/page.tsx + ├── knowledge/page.tsx + ├── (option) scheduler/page.tsx + ├── layout.tsx + └── page.tsx +frontend/lib/api/aiAgent.ts +``` + +--- + +## 12. 변경 이력 +| 일자 | 작성자 | 변경 | +|---|---|---| +| 2026-04-27 | planner | 최초 작성 | diff --git a/.omc/plans/open-questions.md b/.omc/plans/open-questions.md new file mode 100644 index 00000000..cc872598 --- /dev/null +++ b/.omc/plans/open-questions.md @@ -0,0 +1,33 @@ +# Open Questions + +## ai-orchestration-replacement-prd - 2026-04-27 + +### Architect 결정 필요 (Resolve in next BMAD step) +- [ ] DB 마이그레이션 도구 선택 — MyBatis 수동 SQL vs Flyway vs Liquibase. 영향: Epic B 전반 +- [ ] OpenClaw 배포 방식 — npm global 컨테이너 vs Docker Hub 이미지 vs Dockerfile 작성. 영향: Epic E 시작 가능 여부 +- [ ] OpenClaw config 동기화 방식 — 공유 볼륨 파일 vs HTTP API. 영향: Story C11, E4 +- [ ] Spring AI / Spring AI MCP 도입 여부 — 직접 HTTP 클라이언트 유지 vs Spring AI 채택. 영향: Story C1 (LlmClient 추상화 깊이) +- [ ] 스케줄러 구현 — Spring `@Scheduled` vs Quartz. 영향: Story C10, NFR(클러스터링) +- [ ] invyone에 vexplor 커넥터 테이블 존재 여부 — `external_db_connections`/`external_rest_api_connections`/`crawl_configs`/`pipeline_device_connections`. 영향: Story C3, C9 (커넥터 미존재 시 P2 디그레이드) +- [ ] API 키 폐기 정책 — hard delete (vexplor) vs soft delete. 영향: 감사/규제 요구사항 +- [ ] AES-256 암호화 키 관리 — 환경변수 vs Vault vs KMS. 영향: 보안 NFR +- [ ] LLM 스트리밍 응답 구현 — Spring MVC SSE vs WebFlux. 영향: 빌드 의존성 +- [ ] Rate limit 구현 — 인메모리 vs Redis. 영향: 클러스터 배포 가능성 +- [ ] 테스트 전략 — testcontainers PostgreSQL + WireMock LLM vs 별도 dev DB +- [ ] cron 타임존 — 서버 시간 vs UTC. 영향: 스케줄 표시/실행 일관성 + +### 사용자 확인 필요 (Block before proceeding) +- [ ] (Q1) helm-charts 리포지토리(`gitlab.kpslp.kr/root/helm-charts/values_invyone.yaml`)에 ai-assistant 이미지 참조 잔재 존재 여부 — Epic A 완료 시점에 별도 PR 필요 여부 결정 +- [ ] (Q2) **CRITICAL** — ai-assistant Sequelize가 사용한 DB schema 이름 / 호스트. invyone 본 DB와 동일 schema에서 `users`/`api_keys` 테이블이 동작했다면 drop 시 본 시스템 영향 가능. A7 실행 전 필수 확인 +- [ ] (Q3) 스케줄러 페이지 — vexplor에 미포함, invyone에서 신규 작성 필요한지 (F7 Story 활성화 여부) + +### Executor 단계에서 결정 가능 +- [ ] 커밋 분할 전략 — Epic 단위 vs Story 단위 +- [ ] ai-assistant 제거 시점 — B 시작 전 vs C 진행 중 (병행 가능) +- [ ] 페이지 점진 이식 vs 일괄 교체 + +### 산출물 검증 가정 +- [ ] vexplor 측 PostgreSQL JSONB 사용이 invyone PG 16에서 동일 동작 +- [ ] invyone JWT claim에 `userType`(SUPER_ADMIN/COMPANY_ADMIN), `companyCode` 포함 확인 +- [ ] OpenClaw 컨테이너 추가에 대한 운영팀 승인 +- [ ] 외부 호출 트래픽이 분당 100 req 미만(rate limit 인메모리 가능) diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 00000000..e2747261 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,596 @@ +{ + "version": "1.0.0", + "lastScanned": 1777285161379, + "projectRoot": "C:\\Dev\\projects\\invyone", + "techStack": { + "languages": [ + { + "name": "JavaScript/TypeScript", + "version": null, + "confidence": "high", + "markers": [ + "package.json" + ] + } + ], + "frameworks": [ + { + "name": "playwright", + "version": "1.58.2", + "category": "testing" + } + ], + "packageManager": "npm", + "runtime": null + }, + "build": { + "buildCommand": null, + "testCommand": null, + "lintCommand": null, + "devCommand": null, + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "docs", + "scripts" + ], + "gitBranches": { + "defaultBranch": "gbpark-node", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "ai-assistant": { + "path": "ai-assistant", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1777285161317, + "keyFiles": [ + "Dockerfile.win", + "package-lock.json", + "package.json", + "README.md" + ] + }, + "backend": { + "path": "backend", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1777285161317, + "keyFiles": [] + }, + "backend-node": { + "path": "backend-node", + "purpose": null, + "fileCount": 14, + "lastAccessed": 1777285161330, + "keyFiles": [ + "API_연동_가이드.md", + "API_키_정리.md", + "install-multer.js", + "jest.config.js", + "nodemon.json" + ] + }, + "backend-spring": { + "path": "backend-spring", + "purpose": null, + "fileCount": 4, + "lastAccessed": 1777285161331, + "keyFiles": [ + "build.gradle", + "gradlew", + "gradlew.bat", + "settings.gradle" + ] + }, + "db": { + "path": "db", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1777285161332, + "keyFiles": [ + "00-create-roles.sh", + "migrate_company13_export.sh" + ] + }, + "docker": { + "path": "docker", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1777285161332, + "keyFiles": [] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 33, + "lastAccessed": 1777285161333, + "keyFiles": [ + "backend-analysis-README.md", + "backend-analysis-response.json", + "backend-api-route-mapping.md", + "backend-architecture-analysis.md", + "backend-architecture-detailed-analysis.md" + ] + }, + "frontend": { + "path": "frontend", + "purpose": null, + "fileCount": 26, + "lastAccessed": 1777285161333, + "keyFiles": [ + "admin-test-result.md", + "approval-box-result.png", + "components.json", + "cosmic-preview.html", + "eslint.config.mjs" + ] + }, + "k8s": { + "path": "k8s", + "purpose": null, + "fileCount": 10, + "lastAccessed": 1777285161334, + "keyFiles": [ + "backend-node.yaml", + "backend-spring.yaml", + "configmap.yaml", + "deploy.sh", + "frontend.yaml" + ] + }, + "mcp-agent-orchestrator": { + "path": "mcp-agent-orchestrator", + "purpose": null, + "fileCount": 4, + "lastAccessed": 1777285161335, + "keyFiles": [ + "package-lock.json", + "package.json", + "README.md", + "tsconfig.json" + ] + }, + "notes": { + "path": "notes", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1777285161337, + "keyFiles": [] + }, + "scripts": { + "path": "scripts", + "purpose": "Build/utility scripts", + "fileCount": 11, + "lastAccessed": 1777285161337, + "keyFiles": [ + "add-modal-ids.py", + "analyze-company-info-layout.js", + "browser-test-admin-switch-button.js", + "browser-test-customer-crud.js", + "browser-test-customer-via-menu.js" + ] + }, + "test-output": { + "path": "test-output", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1777285161338, + "keyFiles": [ + "screen-149-field-type-verification-guide.md", + "unified-field-type-config-panel-test-guide.md" + ] + }, + "test-results": { + "path": "test-results", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1777285161338, + "keyFiles": [] + }, + "_pipeline_backup": { + "path": "_pipeline_backup", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1777285161339, + "keyFiles": [ + "pipeline-state.json" + ] + }, + "ai-assistant\\src": { + "path": "ai-assistant\\src", + "purpose": "Source code", + "fileCount": 1, + "lastAccessed": 1777285161340, + "keyFiles": [ + "app.js" + ] + }, + "backend-node\\data": { + "path": "backend-node\\data", + "purpose": "Data files", + "fileCount": 0, + "lastAccessed": 1777285161341, + "keyFiles": [] + }, + "backend-spring\\build": { + "path": "backend-spring\\build", + "purpose": "Build output", + "fileCount": 1, + "lastAccessed": 1777285161341, + "keyFiles": [ + "resolvedMainClassName" + ] + }, + "backend-spring\\src": { + "path": "backend-spring\\src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1777285161342, + "keyFiles": [] + }, + "db\\migrations": { + "path": "db\\migrations", + "purpose": "Database migrations", + "fileCount": 16, + "lastAccessed": 1777285161343, + "keyFiles": [ + "046_MIGRATION_FIX.md", + "046_QUICK_FIX.md", + "README_1003.md" + ] + }, + "db\\scripts": { + "path": "db\\scripts", + "purpose": "Build/utility scripts", + "fileCount": 1, + "lastAccessed": 1777285161343, + "keyFiles": [ + "README_cleanup.md" + ] + }, + "frontend\\app": { + "path": "frontend\\app", + "purpose": "Application code", + "fileCount": 5, + "lastAccessed": 1777285161344, + "keyFiles": [ + "favicon.ico", + "globals.css", + "layout.tsx" + ] + }, + "frontend\\components": { + "path": "frontend\\components", + "purpose": "UI components", + "fileCount": 1, + "lastAccessed": 1777285161345, + "keyFiles": [ + "GlobalFileViewer.tsx" + ] + }, + "mcp-agent-orchestrator\\src": { + "path": "mcp-agent-orchestrator\\src", + "purpose": "Source code", + "fileCount": 1, + "lastAccessed": 1777285161345, + "keyFiles": [ + "index.ts" + ] + } + }, + "hotPaths": [ + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\security\\SecurityConfig.java", + "accessCount": 10, + "lastAccessed": 1777297528541, + "type": "file" + }, + { + "path": "frontend\\components\\layout\\AdminPageRenderer.tsx", + "accessCount": 8, + "lastAccessed": 1777297133296, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\resources\\application.yml", + "accessCount": 6, + "lastAccessed": 1777296723408, + "type": "file" + }, + { + "path": "docker-compose.backend.win.yml", + "accessCount": 6, + "lastAccessed": 1777328965297, + "type": "file" + }, + { + "path": "", + "accessCount": 6, + "lastAccessed": 1777329340127, + "type": "directory" + }, + { + "path": "backend-spring\\src", + "accessCount": 5, + "lastAccessed": 1777296597185, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\service\\AiAssistantProxyService.java", + "accessCount": 3, + "lastAccessed": 1777296304775, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\ai\\health\\OpenClawHealthIndicator.java", + "accessCount": 3, + "lastAccessed": 1777296500455, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\ai\\client\\OpenClawClient.java", + "accessCount": 3, + "lastAccessed": 1777297100879, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\controller\\AiAssistantProxyController.java", + "accessCount": 2, + "lastAccessed": 1777292122594, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\ai\\config\\OpenClawProperties.java", + "accessCount": 2, + "lastAccessed": 1777296453350, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\ai\\exception\\OpenClawException.java", + "accessCount": 2, + "lastAccessed": 1777296454246, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp", + "accessCount": 2, + "lastAccessed": 1777296462858, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\config\\MyBatisConfig.java", + "accessCount": 2, + "lastAccessed": 1777296476543, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V002__create_ai_agents.sql", + "accessCount": 2, + "lastAccessed": 1777296498694, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V004__create_ai_agent_group_members.sql", + "accessCount": 2, + "lastAccessed": 1777296499056, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V005__create_ai_agent_api_keys.sql", + "accessCount": 2, + "lastAccessed": 1777296499857, + "type": "file" + }, + { + "path": "frontend", + "accessCount": 2, + "lastAccessed": 1777296636910, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V001__create_ai_llm_providers.sql", + "accessCount": 2, + "lastAccessed": 1777297213582, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources", + "accessCount": 2, + "lastAccessed": 1777297282052, + "type": "directory" + }, + { + "path": "docker-compose.frontend.win.yml", + "accessCount": 2, + "lastAccessed": 1777328980655, + "type": "directory" + }, + { + "path": "ai-assistant\\src\\app.js", + "accessCount": 1, + "lastAccessed": 1777291729219, + "type": "file" + }, + { + "path": "Jenkinsfile", + "accessCount": 1, + "lastAccessed": 1777292137142, + "type": "file" + }, + { + "path": "frontend\\docs\\AI_어시스턴트_메뉴_등록_가이드.md", + "accessCount": 1, + "lastAccessed": 1777292137475, + "type": "file" + }, + { + "path": "ai-assistant\\package.json", + "accessCount": 1, + "lastAccessed": 1777292156893, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java", + "accessCount": 1, + "lastAccessed": 1777292158334, + "type": "directory" + }, + { + "path": "ai-assistant\\src\\models", + "accessCount": 1, + "lastAccessed": 1777292170037, + "type": "directory" + }, + { + "path": "frontend\\lib\\api\\aiAssistant", + "accessCount": 1, + "lastAccessed": 1777292171252, + "type": "directory" + }, + { + "path": "ai-assistant\\.env.example", + "accessCount": 1, + "lastAccessed": 1777293988045, + "type": "file" + }, + { + "path": "ai-assistant\\src\\models\\index.js", + "accessCount": 1, + "lastAccessed": 1777293989107, + "type": "file" + }, + { + "path": "ai-assistant\\src\\models\\user.model.js", + "accessCount": 1, + "lastAccessed": 1777293989222, + "type": "file" + }, + { + "path": "ai-assistant\\src\\models\\api-key.model.js", + "accessCount": 1, + "lastAccessed": 1777293990287, + "type": "file" + }, + { + "path": "ai-assistant\\src\\models\\llm-provider.model.js", + "accessCount": 1, + "lastAccessed": 1777293990609, + "type": "file" + }, + { + "path": "ai-assistant\\src\\models\\usage-log.model.js", + "accessCount": 1, + "lastAccessed": 1777293990839, + "type": "file" + }, + { + "path": "ai-assistant", + "accessCount": 1, + "lastAccessed": 1777293992318, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\security", + "accessCount": 1, + "lastAccessed": 1777294130003, + "type": "directory" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V003__create_ai_agent_groups.sql", + "accessCount": 1, + "lastAccessed": 1777296304942, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V006__create_ai_agent_conversations.sql", + "accessCount": 1, + "lastAccessed": 1777296317538, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V007__create_ai_agent_messages.sql", + "accessCount": 1, + "lastAccessed": 1777296319127, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V008__create_ai_agent_usage_logs.sql", + "accessCount": 1, + "lastAccessed": 1777296323195, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V009__create_ai_agent_schedules.sql", + "accessCount": 1, + "lastAccessed": 1777296326054, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V010__create_ai_analysis_logs.sql", + "accessCount": 1, + "lastAccessed": 1777296329376, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V011__create_ai_knowledge_files.sql", + "accessCount": 1, + "lastAccessed": 1777296334986, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V012__create_quartz_tables.sql", + "accessCount": 1, + "lastAccessed": 1777296359719, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\migration\\V013__create_ai_indexes.sql", + "accessCount": 1, + "lastAccessed": 1777296364743, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\resources\\db\\cleanup\\drop_ai_assistant_db.sql", + "accessCount": 1, + "lastAccessed": 1777296381320, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\controller\\RoleController.java", + "accessCount": 1, + "lastAccessed": 1777296382933, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\dto\\ApiResponse.java", + "accessCount": 1, + "lastAccessed": 1777296431064, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\service\\AdminService.java", + "accessCount": 1, + "lastAccessed": 1777296435916, + "type": "file" + }, + { + "path": "backend-spring\\src\\main\\java\\com\\erp\\controller\\AdminController.java", + "accessCount": 1, + "lastAccessed": 1777296436288, + "type": "file" + } + ], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/state/agent-replay-ac02431b-1ea5-4997-8204-b2bbbcab174f.jsonl b/.omc/state/agent-replay-ac02431b-1ea5-4997-8204-b2bbbcab174f.jsonl new file mode 100644 index 00000000..5113c25c --- /dev/null +++ b/.omc/state/agent-replay-ac02431b-1ea5-4997-8204-b2bbbcab174f.jsonl @@ -0,0 +1,34 @@ +{"t":0,"agent":"a781850","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"a7d7327","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"a89e2b1","agent_type":"planner","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a89e2b1","agent_type":"planner","event":"agent_stop","success":true,"duration_ms":487318} +{"t":0,"agent":"ae04d8f","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"a0c44dd","agent_type":"architect","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a0c44dd","agent_type":"architect","event":"agent_stop","success":true,"duration_ms":432075} +{"t":0,"agent":"a0c2e53","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a0c2e53","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":51763} +{"t":0,"agent":"a997ceb","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"autopilot"} +{"t":0,"agent":"system","event":"mode_change","mode_from":"none","mode_to":"autopilot"} +{"t":0,"agent":"system","event":"skill_invoked","skill_name":"oh-my-claudecode:autopilot"} +{"t":0,"agent":"a51e4eb","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ab16ada","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"acea08b","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"aa78406","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"acea08b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":228388} +{"t":0,"agent":"ab16ada","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":296000} +{"t":0,"agent":"a51e4eb","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":337914} +{"t":0,"agent":"a8cc251","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a317ee0","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a8cc251","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":140638} +{"t":0,"agent":"a317ee0","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":529803} +{"t":0,"agent":"ada3d4e","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"aa78406","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":847770} +{"t":0,"agent":"ada3d4e","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":191403} +{"t":0,"agent":"a94527b","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a94527b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":412139} +{"t":0,"agent":"system","event":"skill_invoked","skill_name":"oh-my-claudecode:cancel"} +{"t":0,"agent":"a1aced0","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a1aced0","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139387} +{"t":0,"agent":"a0d3cd3","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"a4fba8f","agent_type":"unknown","event":"agent_stop","success":true} diff --git a/.omc/state/agent-replay-c7af2efd-4576-4263-a2ec-53492f6628e2.jsonl b/.omc/state/agent-replay-c7af2efd-4576-4263-a2ec-53492f6628e2.jsonl new file mode 100644 index 00000000..3243579b --- /dev/null +++ b/.omc/state/agent-replay-c7af2efd-4576-4263-a2ec-53492f6628e2.jsonl @@ -0,0 +1,13 @@ +{"t":0,"agent":"aa9ac85","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"a09bc21","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrathink"} +{"t":0,"agent":"a38f657","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"ae1a7b4","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrathink"} +{"t":0,"agent":"a86b238","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrathink"} +{"t":0,"agent":"a8210f1","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"acfaccf","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrathink"} +{"t":0,"agent":"a154f20","agent_type":"unknown","event":"agent_stop","success":true} +{"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrathink"} diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 00000000..e71152c9 --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"ac02431b-1ea5-4997-8204-b2bbbcab174f","transcript_path":"C:\\Users\\ramse\\.claude\\projects\\C--Dev-projects-invyone\\ac02431b-1ea5-4997-8204-b2bbbcab174f.jsonl","cwd":"C:\\Dev\\projects\\invyone","model":{"id":"claude-opus-4-7[1m]","display_name":"Opus 4.7 (1M context)"},"workspace":{"current_dir":"C:\\Dev\\projects\\invyone","project_dir":"C:\\Dev\\projects\\invyone","added_dirs":[]},"version":"2.1.119","output_style":{"name":"default"},"cost":{"total_cost_usd":55.235730299999986,"total_duration_ms":44379071,"total_api_duration_ms":4841115,"total_lines_added":9232,"total_lines_removed":239},"context_window":{"total_input_tokens":2511,"total_output_tokens":370081,"context_window_size":1000000,"current_usage":{"input_tokens":6,"output_tokens":894,"cache_creation_input_tokens":943,"cache_read_input_tokens":303528},"used_percentage":30,"remaining_percentage":70},"exceeds_200k_tokens":true,"fast_mode":false,"effort":{"level":"xhigh"},"thinking":{"enabled":true},"rate_limits":{"five_hour":{"used_percentage":19,"resets_at":1777339800},"seven_day":{"used_percentage":32,"resets_at":1777482000}}} \ No newline at end of file diff --git a/.omc/state/last-tool-error.json b/.omc/state/last-tool-error.json new file mode 100644 index 00000000..8a7a15b2 --- /dev/null +++ b/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"docker ps --format \\\"table {{.Names}}\\\\t{{.Status}}\\\\t{{.Ports}}\\\"\",\"description\":\"Check container statuses\"}", + "error": "Exit code 1\nerror during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.48/containers/json\": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.", + "timestamp": "2026-04-27T22:30:37.764Z", + "retry_count": 2 +} \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json new file mode 100644 index 00000000..e32cbf58 --- /dev/null +++ b/.omc/state/mission-state.json @@ -0,0 +1,195 @@ +{ + "updatedAt": "2026-04-27T22:00:14.488Z", + "missions": [ + { + "id": "session:ac02431b-1ea5-4997-8204-b2bbbcab174f:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-04-27T12:13:45.719Z", + "updatedAt": "2026-04-27T22:00:14.488Z", + "status": "done", + "workerCount": 12, + "taskCounts": { + "total": 12, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 12, + "failed": 0 + }, + "agents": [ + { + "name": "planner:a89e2b1", + "role": "planner", + "ownership": "a89e2b13a07d3a60a", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T22:00:14.488Z" + }, + { + "name": "architect:a0c44dd", + "role": "architect", + "ownership": "a0c44ddcc9f1ae1f2", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T12:55:16.251Z" + }, + { + "name": "executor:a0c2e53", + "role": "executor", + "ownership": "a0c2e531739cf1a5f", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T12:56:56.423Z" + }, + { + "name": "executor:a51e4eb", + "role": "executor", + "ownership": "a51e4eba42b48da04", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:29:16.906Z" + }, + { + "name": "executor:ab16ada", + "role": "executor", + "ownership": "ab16ada53ac819e74", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:28:59.529Z" + }, + { + "name": "executor:acea08b", + "role": "executor", + "ownership": "acea08b23eb620b14", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:28:21.439Z" + }, + { + "name": "executor:aa78406", + "role": "executor", + "ownership": "aa78406b52bf55c8f", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:40:10.074Z" + }, + { + "name": "executor:a8cc251", + "role": "executor", + "ownership": "a8cc25174fe56dc0c", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:31:58.008Z" + }, + { + "name": "executor:a317ee0", + "role": "executor", + "ownership": "a317ee06d14085a4f", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:39:12.228Z" + }, + { + "name": "executor:ada3d4e", + "role": "executor", + "ownership": "ada3d4ebf0605ba01", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:43:05.657Z" + }, + { + "name": "executor:a94527b", + "role": "executor", + "ownership": "a94527b228b96ca4d", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T13:50:54.260Z" + }, + { + "name": "executor:a1aced0", + "role": "executor", + "ownership": "a1aced03cfcd64304", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-04-27T20:44:52.394Z" + } + ], + "timeline": [ + { + "id": "session-start:a94527b228b96ca4d:2026-04-27T13:44:02.121Z", + "at": "2026-04-27T13:44:02.121Z", + "kind": "update", + "agent": "executor:a94527b", + "detail": "started executor:a94527b", + "sourceKey": "session-start:a94527b228b96ca4d" + }, + { + "id": "session-stop:a94527b228b96ca4d:2026-04-27T13:50:54.260Z", + "at": "2026-04-27T13:50:54.260Z", + "kind": "completion", + "agent": "executor:a94527b", + "detail": "completed", + "sourceKey": "session-stop:a94527b228b96ca4d" + }, + { + "id": "session-start:a1aced03cfcd64304:2026-04-27T20:42:33.007Z", + "at": "2026-04-27T20:42:33.007Z", + "kind": "update", + "agent": "executor:a1aced0", + "detail": "started executor:a1aced0", + "sourceKey": "session-start:a1aced03cfcd64304" + }, + { + "id": "session-stop:a1aced03cfcd64304:2026-04-27T20:44:52.394Z", + "at": "2026-04-27T20:44:52.394Z", + "kind": "completion", + "agent": "executor:a1aced0", + "detail": "completed", + "sourceKey": "session-stop:a1aced03cfcd64304" + }, + { + "id": "session-stop:a0d3cd37785105e78:2026-04-27T20:50:01.582Z", + "at": "2026-04-27T20:50:01.582Z", + "kind": "completion", + "agent": "planner:a89e2b1", + "detail": "completed", + "sourceKey": "session-stop:a0d3cd37785105e78" + }, + { + "id": "session-stop:a4fba8fcce9081ce9:2026-04-27T22:00:14.488Z", + "at": "2026-04-27T22:00:14.488Z", + "kind": "completion", + "agent": "planner:a89e2b1", + "detail": "completed", + "sourceKey": "session-stop:a4fba8fcce9081ce9" + } + ] + } + ] +} \ No newline at end of file diff --git a/.omc/state/sessions/ac02431b-1ea5-4997-8204-b2bbbcab174f/hud-state.json b/.omc/state/sessions/ac02431b-1ea5-4997-8204-b2bbbcab174f/hud-state.json new file mode 100644 index 00000000..0b54e064 --- /dev/null +++ b/.omc/state/sessions/ac02431b-1ea5-4997-8204-b2bbbcab174f/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-04-27T11:33:26.939Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-04-27T10:19:21.354Z", + "sessionId": "ac02431b-1ea5-4997-8204-b2bbbcab174f" +} \ No newline at end of file diff --git a/.omc/state/sessions/c7af2efd-4576-4263-a2ec-53492f6628e2/hud-state.json b/.omc/state/sessions/c7af2efd-4576-4263-a2ec-53492f6628e2/hud-state.json new file mode 100644 index 00000000..01b0ab03 --- /dev/null +++ b/.omc/state/sessions/c7af2efd-4576-4263-a2ec-53492f6628e2/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-04-21T23:16:09.612Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-04-21T23:14:36.455Z", + "sessionId": "c7af2efd-4576-4263-a2ec-53492f6628e2" +} \ No newline at end of file diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json new file mode 100644 index 00000000..60ad044d --- /dev/null +++ b/.omc/state/subagent-tracking.json @@ -0,0 +1,116 @@ +{ + "agents": [ + { + "agent_id": "a89e2b13a07d3a60a", + "agent_type": "planner", + "started_at": "2026-04-27T12:13:45.719Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T12:21:53.037Z", + "duration_ms": 487318 + }, + { + "agent_id": "a0c44ddcc9f1ae1f2", + "agent_type": "architect", + "started_at": "2026-04-27T12:48:04.176Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T12:55:16.251Z", + "duration_ms": 432075 + }, + { + "agent_id": "a0c2e531739cf1a5f", + "agent_type": "executor", + "started_at": "2026-04-27T12:56:04.660Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T12:56:56.423Z", + "duration_ms": 51763 + }, + { + "agent_id": "a51e4eba42b48da04", + "agent_type": "executor", + "started_at": "2026-04-27T13:23:38.992Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:29:16.906Z", + "duration_ms": 337914 + }, + { + "agent_id": "ab16ada53ac819e74", + "agent_type": "executor", + "started_at": "2026-04-27T13:24:03.529Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:28:59.529Z", + "duration_ms": 296000 + }, + { + "agent_id": "acea08b23eb620b14", + "agent_type": "executor", + "started_at": "2026-04-27T13:24:33.051Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:28:21.439Z", + "duration_ms": 228388 + }, + { + "agent_id": "aa78406b52bf55c8f", + "agent_type": "executor", + "started_at": "2026-04-27T13:26:02.304Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:40:10.074Z", + "duration_ms": 847770 + }, + { + "agent_id": "a8cc25174fe56dc0c", + "agent_type": "executor", + "started_at": "2026-04-27T13:29:37.370Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:31:58.008Z", + "duration_ms": 140638 + }, + { + "agent_id": "a317ee06d14085a4f", + "agent_type": "executor", + "started_at": "2026-04-27T13:30:22.425Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:39:12.228Z", + "duration_ms": 529803 + }, + { + "agent_id": "ada3d4ebf0605ba01", + "agent_type": "executor", + "started_at": "2026-04-27T13:39:54.254Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:43:05.657Z", + "duration_ms": 191403 + }, + { + "agent_id": "a94527b228b96ca4d", + "agent_type": "executor", + "started_at": "2026-04-27T13:44:02.121Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T13:50:54.260Z", + "duration_ms": 412139 + }, + { + "agent_id": "a1aced03cfcd64304", + "agent_type": "executor", + "started_at": "2026-04-27T20:42:33.007Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-04-27T20:44:52.394Z", + "duration_ms": 139387 + } + ], + "total_spawned": 12, + "total_completed": 12, + "total_failed": 0, + "last_updated": "2026-04-27T22:00:14.590Z" +} \ No newline at end of file diff --git a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java index 35ea14ee..ebad3e01 100644 --- a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java +++ b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java @@ -39,7 +39,7 @@ public class SecurityConfig { 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}") diff --git a/backend-spring/src/main/resources/db/migration/V001__create_ai_llm_providers.sql b/backend-spring/src/main/resources/db/migration/V001__create_ai_llm_providers.sql new file mode 100644 index 00000000..4c57e52a --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V001__create_ai_llm_providers.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V002__create_ai_agents.sql b/backend-spring/src/main/resources/db/migration/V002__create_ai_agents.sql new file mode 100644 index 00000000..991443d7 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V002__create_ai_agents.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V003__create_ai_agent_groups.sql b/backend-spring/src/main/resources/db/migration/V003__create_ai_agent_groups.sql new file mode 100644 index 00000000..c9aace1c --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V003__create_ai_agent_groups.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V004__create_ai_agent_group_members.sql b/backend-spring/src/main/resources/db/migration/V004__create_ai_agent_group_members.sql new file mode 100644 index 00000000..545f5888 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V004__create_ai_agent_group_members.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V005__create_ai_agent_api_keys.sql b/backend-spring/src/main/resources/db/migration/V005__create_ai_agent_api_keys.sql new file mode 100644 index 00000000..381e6dfa --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V005__create_ai_agent_api_keys.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V006__create_ai_agent_conversations.sql b/backend-spring/src/main/resources/db/migration/V006__create_ai_agent_conversations.sql new file mode 100644 index 00000000..95138dc8 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V006__create_ai_agent_conversations.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V007__create_ai_agent_messages.sql b/backend-spring/src/main/resources/db/migration/V007__create_ai_agent_messages.sql new file mode 100644 index 00000000..ddb5e23e --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V007__create_ai_agent_messages.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V008__create_ai_agent_usage_logs.sql b/backend-spring/src/main/resources/db/migration/V008__create_ai_agent_usage_logs.sql new file mode 100644 index 00000000..2c6d21f2 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V008__create_ai_agent_usage_logs.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V009__create_ai_agent_schedules.sql b/backend-spring/src/main/resources/db/migration/V009__create_ai_agent_schedules.sql new file mode 100644 index 00000000..f20f8071 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V009__create_ai_agent_schedules.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V010__create_ai_analysis_logs.sql b/backend-spring/src/main/resources/db/migration/V010__create_ai_analysis_logs.sql new file mode 100644 index 00000000..a8cc4417 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V010__create_ai_analysis_logs.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V011__create_ai_knowledge_files.sql b/backend-spring/src/main/resources/db/migration/V011__create_ai_knowledge_files.sql new file mode 100644 index 00000000..3b8bcd5b --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V011__create_ai_knowledge_files.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V012__create_quartz_tables.sql b/backend-spring/src/main/resources/db/migration/V012__create_quartz_tables.sql new file mode 100644 index 00000000..3ed8766e --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V012__create_quartz_tables.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V013__create_ai_indexes.sql b/backend-spring/src/main/resources/db/migration/V013__create_ai_indexes.sql new file mode 100644 index 00000000..3e609e41 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V013__create_ai_indexes.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V014__register_ai_menus.sql b/backend-spring/src/main/resources/db/migration/V014__register_ai_menus.sql new file mode 100644 index 00000000..31b82bb4 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V014__register_ai_menus.sql @@ -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; diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 880de1cf..f3160e2a 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: - backend_data:/app/data labels: - traefik.enable=true - - traefik.http.routers.backend.rule=Host(`api.invion.com`) + - traefik.http.routers.backend.rule=Host(`api.invyone.com`) - traefik.http.routers.backend.entrypoints=websecure,web - traefik.http.routers.backend.tls=true - traefik.http.routers.backend.tls.certresolver=le @@ -34,12 +34,12 @@ services: context: ../../frontend dockerfile: ../docker/deploy/frontend.Dockerfile args: - - NEXT_PUBLIC_API_URL=https://api.invion.com/api + - NEXT_PUBLIC_API_URL=https://api.invyone.com/api container_name: pms-frontend-prod restart: always environment: NODE_ENV: production - NEXT_PUBLIC_API_URL: https://api.invion.com/api + NEXT_PUBLIC_API_URL: https://api.invyone.com/api SERVER_API_URL: "http://backend:8081" NEXT_TELEMETRY_DISABLED: "1" PORT: "3000" @@ -48,7 +48,7 @@ services: - frontend_data:/app/data labels: - traefik.enable=true - - traefik.http.routers.frontend.rule=Host(`v1.invion.com`) + - traefik.http.routers.frontend.rule=Host(`v1.invyone.com`) - traefik.http.routers.frontend.entrypoints=websecure,web - traefik.http.routers.frontend.tls=true - traefik.http.routers.frontend.tls.certresolver=le diff --git a/docs/DOMAIN_MAPPING.md b/docs/DOMAIN_MAPPING.md new file mode 100644 index 00000000..359855b7 --- /dev/null +++ b/docs/DOMAIN_MAPPING.md @@ -0,0 +1,64 @@ +# 도메인 매핑 (운영/개발 환경) + +> ⚠️ AI 에이전트와 신규 개발자가 헷갈리지 않도록 영구 기록. +> 변경 시 반드시 본 문서 + `.cursor/rules/api-client-usage.mdc` + `.cursor/rules/project-conventions.mdc` 를 함께 갱신. + +## 운영 환경 + +| 역할 | 도메인 | 비고 | +|---|---|---| +| Frontend (정식) | `solution.invyone.com` | 사용자 진입 URL | +| Frontend (alias) | `v1.invyone.com` | 같은 frontend, 백업/구버전 alias | +| Backend Spring API | `api.invyone.com` | `https://api.invyone.com/api/...` | +| Backend Node API | `node-api.invyone.com` | (legacy / 일부 endpoint) | + +DNS 검증 (확인 일자: 2026-04-28) +- `solution.invyone.com` → 200 +- `v1.invyone.com` → 200 +- `api.invyone.com` → 200 +- `node-api.invyone.com` → 200 + +## 폐기된 도메인 (사용 금지) + +| 도메인 | 상태 | +|---|---| +| `v1.invion.com` | DNS 미존재. 이전 코드/문서에 박혀 있어 2026-04-28 일괄 정리 | +| `api.invion.com` | 동일 | +| `node-api.invion.com` | 동일 | +| `solution.invion.com` | 동일 | + +> "invion" → "invyone" 일괄 치환 완료. 신규 코드/문서는 절대 `invion.com` 사용 금지. + +## 개발 환경 + +| 역할 | 주소 | 비고 | +|---|---|---| +| Frontend dev | `http://localhost:9771` | docker-compose.frontend.win.yml | +| Backend Spring | `http://localhost:8081` | docker-compose.backend.win.yml | + +## 인증 정보 + +### 로컬 (`.cursor/rules/web-verify-login.mdc`) +- 아이디: `wace` / 비밀번호: `qlalfqjsgh11` — **로컬 전용** + +### 운영 (`solution.invyone.com`) +- 운영 비밀번호는 코드/문서에 평문 저장 금지. +- 사용자에게 직접 문의해서 입력 받을 것. +- AI 에이전트는 로컬 비밀번호로 운영 로그인 시도 X. + +## API 호출 흐름 + +``` +브라우저 (solution.invyone.com) + ↓ apiClient (lib/api/client.ts) + ↓ baseURL = https://${currentHost}/api (1순위, *.invyone.com 자동 매칭) +운영 Traefik (k8s/traefik-dynamic.yaml) + ↓ Host(`solution.invyone.com`) && PathPrefix(`/api`) → backend +Spring Boot (api.invyone.com 직접 OR Traefik 내부) +``` + +`apiClient` 우선순위: +1. `*.invyone.com` 호스트 → `https://${currentHost}/api` (서브도메인 테넌트 대응) +2. `v1.invyone.com` / `solution.invyone.com` 직접 매칭 → `https://api.invyone.com/api` +3. `NEXT_PUBLIC_API_URL` 환경변수 +4. `http://localhost:8081/api` (로컬 기본) diff --git a/docs/MULTI_TENANCY_ARCHITECTURE.md b/docs/MULTI_TENANCY_ARCHITECTURE.md index 255ff619..34782c0d 100644 --- a/docs/MULTI_TENANCY_ARCHITECTURE.md +++ b/docs/MULTI_TENANCY_ARCHITECTURE.md @@ -241,8 +241,8 @@ cors: if (currentHost.endsWith(".invyone.com")) { return `https://${currentHost}/api`; } -// 2) (레거시) invion.com 메인 도메인 -if (currentHost === "v1.invion.com") return "https://api.invion.com/api"; +// 2) (레거시) invyone.com 메인 도메인 +if (currentHost === "v1.invyone.com") return "https://api.invyone.com/api"; // 3) NEXT_PUBLIC_API_URL (docker-compose 주입) // 4) localhost 기본값 ``` diff --git a/docs/WACE_SYSTEM_WORKFLOW.md b/docs/WACE_SYSTEM_WORKFLOW.md index 6957a770..e2ebcc27 100644 --- a/docs/WACE_SYSTEM_WORKFLOW.md +++ b/docs/WACE_SYSTEM_WORKFLOW.md @@ -327,7 +327,7 @@ const res = await getFlowDefinitions(); // ✅ | 환경 | 프론트엔드 | 백엔드 API | |------|-----------|-----------| | 로컬 개발 | localhost:9771 | localhost:8080/api | -| 운영 | v1.invion.com | api.invion.com/api | +| 운영 | v1.invyone.com | api.invyone.com/api | ### 5.5 상태 관리 체계 diff --git a/docs/backend-architecture-detailed-analysis.md b/docs/backend-architecture-detailed-analysis.md index e2d758f2..da7f123d 100644 --- a/docs/backend-architecture-detailed-analysis.md +++ b/docs/backend-architecture-detailed-analysis.md @@ -1695,8 +1695,8 @@ const getCorsOrigin = () => { return [ 'http://localhost:9771', 'http://39.117.244.52:5555', - 'https://v1.invion.com', - 'https://api.invion.com' + 'https://v1.invyone.com', + 'https://api.invyone.com' ]; }; diff --git a/docs/frontend-architecture-analysis.md b/docs/frontend-architecture-analysis.md index cf3a6eb0..878973d5 100644 --- a/docs/frontend-architecture-analysis.md +++ b/docs/frontend-architecture-analysis.md @@ -699,9 +699,9 @@ const getApiBaseUrl = (): string => { // 환경변수 우선 if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; - // 프로덕션: v1.invion.com → api.invion.com - if (currentHost === "v1.invion.com") { - return "https://api.invion.com/api"; + // 프로덕션: v1.invyone.com → api.invyone.com + if (currentHost === "v1.invyone.com") { + return "https://api.invyone.com/api"; } // 로컬: localhost:9771 → localhost:8080 diff --git a/frontend/invion-layout-v5.html b/frontend/invion-layout-v5.html index 0e7a7c4b..b82c637f 100644 --- a/frontend/invion-layout-v5.html +++ b/frontend/invion-layout-v5.html @@ -535,7 +535,7 @@ html:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
P
-
Park님
park@invion.com
+
Park님
park@invyone.com
내 정보
비밀번호 변경
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index a9351b94..f18a677b 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -24,9 +24,10 @@ const getApiBaseUrl = (): string => { return `https://${currentHost}/api`; } - // 2. 프로덕션 메인 도메인 - if (currentHost === "v1.invion.com") { - return "https://api.invion.com/api"; + // 2. 프로덕션 메인 도메인 fallback (1번에서 endsWith 로 이미 처리되므로 dead-code 가깝지만, + // invyone.com 루트 도메인 등 예외 케이스 보호용으로 유지) + if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") { + return "https://api.invyone.com/api"; } } @@ -53,8 +54,8 @@ export const getFullImageUrl = (imagePath: string): string => { if (typeof window !== "undefined") { const currentHost = window.location.hostname; - if (currentHost === "v1.invion.com") { - return `https://api.invion.com${imagePath}`; + if (currentHost.endsWith(".invyone.com")) { + return `https://api.invyone.com${imagePath}`; } if (currentHost === "localhost" || currentHost === "127.0.0.1") { diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 11188c59..02a4527b 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -10,9 +10,9 @@ function getApiBaseUrl(): string { if (typeof window !== "undefined") { const hostname = window.location.hostname; - // 프로덕션: v1.invion.com → https://api.invion.com/api - if (hostname === "v1.invion.com") { - return "https://api.invion.com/api"; + // 프로덕션: v1.invyone.com → https://api.invyone.com/api + if (hostname === "v1.invyone.com") { + return "https://api.invyone.com/api"; } // 로컬 개발: localhost → http://localhost:8081/api diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index ef3dadef..bd61c3dd 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -262,9 +262,9 @@ export const getDirectFileUrl = (filePath: string): string => { if (typeof window !== "undefined") { const currentHost = window.location.hostname; - // 프로덕션 환경: v1.invion.com → api.invion.com - if (currentHost === "v1.invion.com") { - return `https://api.invion.com${filePath}`; + // 프로덕션 환경: v1.invyone.com → api.invyone.com + if (currentHost === "v1.invyone.com") { + return `https://api.invyone.com${filePath}`; } // 로컬 개발환경 @@ -274,7 +274,7 @@ export const getDirectFileUrl = (filePath: string): string => { } // SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback) - // 주의: 프로덕션 URL이 https://api.invion.com/api 이므로 + // 주의: 프로덕션 URL이 https://api.invyone.com/api 이므로 // 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생 const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, "") || ""; if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index a49f02b2..bfd7c762 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -30,9 +30,9 @@ const getApiBaseUrl = (): string => { if (typeof window !== "undefined") { const currentHost = window.location.hostname; - // 프로덕션 환경: v1.invion.com → api.invion.com - if (currentHost === "v1.invion.com") { - return "https://api.invion.com/api"; + // 프로덕션 환경: v1.invyone.com → api.invyone.com + if (currentHost === "v1.invyone.com") { + return "https://api.invyone.com/api"; } // 로컬 개발환경 diff --git a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx index 57b7e5e1..194a0fff 100644 --- a/frontend/lib/registry/components/file-upload/FileViewerModal.tsx +++ b/frontend/lib/registry/components/file-upload/FileViewerModal.tsx @@ -284,7 +284,7 @@ export const FileViewerModal: React.FC = ({ file, isOpen, } } else { // 기타 파일은 다운로드 URL 사용 - // 주의: 프로덕션 URL이 https://api.invion.com/api 이므로 + // 주의: 프로덕션 URL이 https://api.invyone.com/api 이므로 // 끝의 /api만 제거해야 호스트명이 깨지지 않음 const url = `${API_BASE_URL.replace(/\/api$/, "")}/api/files/download/${file.objid}`; setPreviewUrl(url); diff --git a/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx index 57b7e5e1..194a0fff 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx @@ -284,7 +284,7 @@ export const FileViewerModal: React.FC = ({ file, isOpen, } } else { // 기타 파일은 다운로드 URL 사용 - // 주의: 프로덕션 URL이 https://api.invion.com/api 이므로 + // 주의: 프로덕션 URL이 https://api.invyone.com/api 이므로 // 끝의 /api만 제거해야 호스트명이 깨지지 않음 const url = `${API_BASE_URL.replace(/\/api$/, "")}/api/files/download/${file.objid}`; setPreviewUrl(url); diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts index f11dd2dc..af2b3536 100644 --- a/frontend/lib/utils/apiUrl.ts +++ b/frontend/lib/utils/apiUrl.ts @@ -8,9 +8,9 @@ export function getApiUrl(endpoint: string): string { if (typeof window !== "undefined") { const hostname = window.location.hostname; - // 프로덕션: v1.invion.com → https://api.invion.com - if (hostname === "v1.invion.com") { - return `https://api.invion.com${endpoint}`; + // 프로덕션: *.invyone.com (solution, v1 등 모든 alias) → https://api.invyone.com + if (hostname.endsWith(".invyone.com")) { + return `https://api.invyone.com${endpoint}`; } // 로컬 개발: localhost → http://localhost:8081 diff --git a/k8s/traefik-dynamic.yaml b/k8s/traefik-dynamic.yaml index af87dcfd..ec7b1bbb 100644 --- a/k8s/traefik-dynamic.yaml +++ b/k8s/traefik-dynamic.yaml @@ -6,7 +6,7 @@ http: routers: # Frontend (Next.js) invyone-frontend: - rule: "Host(`v1.invion.com`)" + rule: "Host(`v1.invyone.com`)" entryPoints: - web - websecure @@ -16,7 +16,7 @@ http: # Backend Spring Boot API invyone-api: - rule: "Host(`api.invion.com`)" + rule: "Host(`api.invyone.com`)" entryPoints: - web - websecure @@ -26,7 +26,7 @@ http: # Backend Node.js API (필요시) invyone-node-api: - rule: "Host(`node-api.invion.com`)" + rule: "Host(`node-api.invyone.com`)" entryPoints: - web - websecure diff --git a/notes/gbpark/2026-04-08-auth-security-audit.md b/notes/gbpark/2026-04-08-auth-security-audit.md index 5f0e90fd..61a1deea 100644 --- a/notes/gbpark/2026-04-08-auth-security-audit.md +++ b/notes/gbpark/2026-04-08-auth-security-audit.md @@ -70,7 +70,7 @@ config.setAllowedOriginPatterns(List.of("*")); config.setAllowCredentials(true); ``` `*` + `allowCredentials=true` 조합은 모든 출처에서 쿠키 포함 요청을 허용. -**조치:** 운영용 화이트리스트 (`v1.invion.com`, 사내 IP 등) 로 좁힐 것. +**조치:** 운영용 화이트리스트 (`v1.invyone.com`, 사내 IP 등) 로 좁힐 것. ### 6. JWT 를 localStorage 저장 + 비-HttpOnly 쿠키 중복 **파일:** `frontend/lib/api/client.ts:83-87`, `frontend/hooks/useAuth.ts:58-64` @@ -154,7 +154,7 @@ const strictProtectedPaths = ["/admin"]; ## 🟢 자잘한 정리 -- **`frontend/lib/api/client.ts:14-36`** — API base URL 결정 로직이 환경변수 + hostname 분기 + 포트 분기로 길어짐. hostname 하드코딩 (`v1.invion.com`) 은 환경변수로 분리 권장. +- **`frontend/lib/api/client.ts:14-36`** — API base URL 결정 로직이 환경변수 + hostname 분기 + 포트 분기로 길어짐. hostname 하드코딩 (`v1.invyone.com`) 은 환경변수로 분리 권장. - **`useAuth.ts:196`, `AuthService.java:273`** — `"plm_admin" === userId` 식의 매직 ID 가 프론트/백 양쪽에 박힘. role 기반으로 일원화. - **`JwtAuthenticationFilter`** — 토큰 만료/위조 시 401 응답 없이 그냥 다음 필터로 넘김 (#4 결함과 맞물려 동작 불일치). - **`frontend/hooks/useLogin.ts:81-95`** — `checkExistingAuth` 가 401 받으면 인터셉터의 자동 redirectToLogin 과 충돌 여지. 현재는 `pathname === "/login"` 가드로 막혀있긴 함. diff --git a/notes/gbpark/2026-04-08-auth-security-fix.md b/notes/gbpark/2026-04-08-auth-security-fix.md index eda80c85..051c5102 100644 --- a/notes/gbpark/2026-04-08-auth-security-fix.md +++ b/notes/gbpark/2026-04-08-auth-security-fix.md @@ -92,7 +92,7 @@ WHERE table_name = 'user_info' AND column_name = 'user_password'; - 현재는 **dev 도커** (`docker/dev/.env`) 만 작성 - 운영 도커 컴포즈 (`docker/prod/...`) 가 있다면 거기에도 동일한 패턴으로 `.env` + secret + CORS 화이트리스트 적용 필요 -- 운영 CORS 화이트리스트: `https://v1.invion.com,https://api.invion.com` 식으로 변경 +- 운영 CORS 화이트리스트: `https://v1.invyone.com,https://api.invyone.com` 식으로 변경 ### D. 마스터 패스워드 의존성 확인 diff --git a/notes/gbpark/2026-04-22-company-mng-mockup.html b/notes/gbpark/2026-04-22-company-mng-mockup.html index 16054d22..62081a62 100644 --- a/notes/gbpark/2026-04-22-company-mng-mockup.html +++ b/notes/gbpark/2026-04-22-company-mng-mockup.html @@ -799,7 +799,7 @@ html.dark .v5-cm-tg{background:var(--v5-cm-sunk);}
- +
diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index 04c7be9f..ee72cef1 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -51,8 +51,8 @@ echo "======================================" echo "배포 완료!" echo "======================================" echo "" -echo "Frontend: https://v1.invion.com" -echo "Backend: https://api.invion.com" +echo "Frontend: https://v1.invyone.com" +echo "Backend: https://api.invyone.com" echo "" docker-compose -f "$COMPOSE_FILE" ps echo ""