merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)

충돌 해결 5개 파일:
- .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존
- docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합
- frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인
- frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리
- frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴)

검증:
- backend: ./gradlew compileJava 성공 (Java 21)
- frontend: 머지된 4개 파일 관련 타입 에러 0개

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-07 16:51:06 +09:00
400 changed files with 23432 additions and 33311 deletions
+5 -5
View File
@@ -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` |
+1 -1
View File
@@ -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` 기반으로 자동 판별
+4
View File
@@ -0,0 +1,4 @@
# Force LF for shell scripts and Gradle wrapper so they work in Linux containers
# regardless of host autocrlf settings.
*.sh text eol=lf
gradlew text eol=lf
+54
View File
@@ -31,6 +31,7 @@ jobs:
- name: Build frontend
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.PROJECT }}/frontend:${{ env.SHORT_SHA }} \
--build-arg GIT_SHA=${{ env.SHORT_SHA }} \
-f docker/deploy/frontend.Dockerfile \
frontend/
@@ -70,3 +71,56 @@ jobs:
# Rollout 상태 확인
kubectl rollout status deployment/backend-spring -n invyone --timeout=180s
kubectl rollout status deployment/frontend -n invyone --timeout=120s
# ---- 실패 시 진단 (pod stdout / events 캡처) ----
- name: Diagnose on failure
if: failure()
run: |
export KUBECONFIG=/home/chpark/.kube/config
echo "============================================"
echo "=== Pods (-n invyone) ==="
echo "============================================"
kubectl get pods -n invyone -o wide || true
echo
echo "============================================"
echo "=== Deployment images (현재 어떤 tag 가 떠있는지) ==="
echo "============================================"
kubectl get deploy -n invyone -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.template.spec.containers[*].image}{"\n"}{end}' || true
echo
echo "============================================"
echo "=== backend-spring describe ==="
echo "============================================"
kubectl describe deployment backend-spring -n invyone || true
kubectl describe pods -n invyone -l app=backend-spring | tail -120 || true
echo
echo "============================================"
echo "=== backend-spring ALL pod logs (per pod) ==="
echo "============================================"
# deployment/<name> selector 는 active ReplicaSet 한 개만 봐서
# 새로 뜨다 죽은 ReplicaSet 의 pod 를 놓침.
# label selector 로 모든 backend-spring pod 순회.
for p in $(kubectl get pods -n invyone -l app=backend-spring -o name 2>/dev/null); do
echo "------------------------------"
echo "--- $p (current, tail 500) ---"
echo "------------------------------"
kubectl logs -n invyone $p --all-containers=true --tail=500 2>&1 || true
echo
echo "--- $p (previous, if exists, tail 500) ---"
kubectl logs -n invyone $p --all-containers=true --tail=500 --previous 2>&1 || true
echo
done
echo
echo "============================================"
echo "=== frontend logs (참고, tail 200) ==="
echo "============================================"
kubectl logs -n invyone deployment/frontend --tail=200 --all-containers=true || true
echo
echo "============================================"
echo "=== Recent Warning events ==="
echo "============================================"
kubectl get events -n invyone --sort-by='.lastTimestamp' --field-selector type=Warning 2>/dev/null | tail -30 || true
+5
View File
@@ -6,6 +6,10 @@ CLAUDE.local.md
.envrc
.direnv/
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
# planning, autopilot state, agent transcript, project memory 등 포함
.omc/
# Syncthing local stub (each machine has its own; real patterns in .stignore-shared)
.stignore
.stfolder/
@@ -127,6 +131,7 @@ tokens.json
# 데이터베이스 덤프
*.sql
!**/db/migration/*.sql
*.dump
db/dump/
db/backup/
-25
View File
@@ -1,25 +0,0 @@
# AI Assistant API (INVION 내장) - 환경 변수
# 이 파일을 .env 로 복사한 뒤 값 설정
NODE_ENV=development
PORT=3100
# PostgreSQL (AI 어시스턴트 전용 DB)
DB_HOST=localhost
DB_PORT=5432
DB_USER=ai_assistant
DB_PASSWORD=ai_assistant_password
DB_NAME=ai_assistant_db
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=30d
# LLM (구글 키 등)
GEMINI_API_KEY=your-gemini-api-key
GEMINI_MODEL=gemini-2.0-flash
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
-17
View File
@@ -1,17 +0,0 @@
# AI 어시스턴트 API - Docker (Windows 개발용)
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=development
EXPOSE 3100
CMD ["node", "src/app.js"]
-43
View File
@@ -1,43 +0,0 @@
# AI 어시스턴트 API (INVION 내장)
INVION와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
## 동작 방식
- **프론트(9771)** → `/api/ai/v1/*` 호출
- **Next.js** → `8080/api/ai/v1/*` 로 rewrite
- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스**
따라서 사용자는 **다른 포트를 쓰지 않고** INVION만 켜도 AI 기능을 사용할 수 있습니다.
## 서비스 올리는 순서 (한 번에 동작하게)
1. **AI 어시스턴트 API (이 폴더, 포트 3100)**
```bash
cd ai-assistant
npm install
cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정
npm start
```
2. **backend-node (포트 8080)**
```bash
cd backend-node
npm run dev
```
3. **프론트 (포트 9771)**
```bash
cd frontend
npm run dev
```
브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다.
## 환경 변수
- `.env.example` 을 `.env` 로 복사 후 수정
- `PORT=3100` (기본값)
- PostgreSQL: `DB_*`
- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET`
- LLM: `GEMINI_API_KEY` 등
-3455
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"name": "ai-assistant-api",
"version": "1.0.0",
"description": "AI Assistant API (INVION 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시",
"private": true,
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"@google/genai": "^1.0.0",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.35.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
}
-186
View File
@@ -1,186 +0,0 @@
// src/app.js
// AI Assistant API 서버 메인 엔트리포인트
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger.config');
const logger = require('./config/logger.config');
const { sequelize } = require('./models');
const routes = require('./routes');
const errorHandler = require('./middlewares/error-handler.middleware');
const app = express();
// INVION 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
const PORT = process.env.PORT || 3100;
// ===========================================
// 미들웨어 설정
// ===========================================
// Trust proxy (Docker/Nginx 환경)
app.set('trust proxy', 1);
// CORS 설정 (helmet보다 먼저 설정)
app.use(cors({
origin: true, // 모든 origin 허용
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
}));
// Preflight 요청 처리
app.options('*', cors());
// 보안 헤더 (CORS 이후에 설정)
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
}));
// 요청 본문 파싱
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 압축
app.use(compression());
// Rate Limiting (전역)
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// 요청 로깅
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
});
// ===========================================
// 헬스 체크
// ===========================================
app.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
});
});
// ===========================================
// Swagger API 문서
// ===========================================
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'AI Assistant API 문서',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
},
}));
// Swagger JSON
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// ===========================================
// API 라우트
// ===========================================
app.use('/api/v1', routes);
// ===========================================
// 404 처리
// ===========================================
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
},
});
});
// ===========================================
// 에러 핸들러
// ===========================================
app.use(errorHandler);
// ===========================================
// 서버 시작
// ===========================================
async function startServer() {
try {
// 데이터베이스 연결
await sequelize.authenticate();
logger.info('✅ 데이터베이스 연결 성공');
// 테이블 동기화 (테이블이 없으면 생성)
await sequelize.sync();
logger.info('✅ 데이터베이스 스키마 동기화 완료');
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
const initService = require('./services/init.service');
await initService.initialize();
// 서버 시작
app.listen(PORT, () => {
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
});
} catch (error) {
logger.error('❌ 서버 시작 실패:', error);
process.exit(1);
}
}
// 프로세스 종료 처리
process.on('SIGTERM', async () => {
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
startServer();
module.exports = app;
@@ -1,474 +0,0 @@
// src/controllers/admin.controller.js
// 관리자 컨트롤러
const { LLMProvider, User, UsageLog, ApiKey } = require('../models');
const { Op } = require('sequelize');
const logger = require('../config/logger.config');
// ===== LLM 프로바이더 관리 =====
/**
* LLM 프로바이더 목록 조회
*/
exports.getProviders = async (req, res, next) => {
try {
const providers = await LLMProvider.findAll({
order: [['priority', 'ASC']],
attributes: [
'id',
'name',
'displayName',
'endpoint',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
'lastHealthCheck',
'createdAt',
'updatedAt',
// API 키는 마스킹해서 반환
'apiKey',
],
});
// API 키 마스킹
const maskedProviders = providers.map((p) => {
const data = p.toJSON();
if (data.apiKey) {
// 앞 8자만 보여주고 나머지는 마스킹
data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4);
data.hasApiKey = true;
} else {
data.hasApiKey = false;
}
return data;
});
return res.json({
success: true,
data: maskedProviders,
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 추가
*/
exports.createProvider = async (req, res, next) => {
try {
const {
name,
displayName,
endpoint,
apiKey,
modelName,
priority = 50,
maxTokens = 4096,
temperature = 0.7,
timeoutMs = 60000,
costPer1kInputTokens = 0,
costPer1kOutputTokens = 0,
} = req.body;
// 중복 이름 확인
const existing = await LLMProvider.findOne({ where: { name } });
if (existing) {
return res.status(409).json({
success: false,
error: {
code: 'PROVIDER_EXISTS',
message: '이미 존재하는 프로바이더 이름입니다.',
},
});
}
const provider = await LLMProvider.create({
name,
displayName,
endpoint,
apiKey,
modelName,
priority,
maxTokens,
temperature,
timeoutMs,
costPer1kInputTokens,
costPer1kOutputTokens,
isActive: true,
isHealthy: true,
});
logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`);
return res.status(201).json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
priority: provider.priority,
isActive: provider.isActive,
message: 'LLM 프로바이더가 추가되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 수정
*/
exports.updateProvider = async (req, res, next) => {
try {
const { id } = req.params;
const updates = req.body;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
// 허용된 필드만 업데이트
const allowedFields = [
'displayName',
'endpoint',
'apiKey',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
];
allowedFields.forEach((field) => {
if (updates[field] !== undefined) {
provider[field] = updates[field];
}
});
await provider.save();
logger.info(`LLM 프로바이더 수정: ${provider.name}`);
return res.json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
isActive: provider.isActive,
message: 'LLM 프로바이더가 수정되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 삭제
*/
exports.deleteProvider = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
const providerName = provider.name;
await provider.destroy();
logger.info(`LLM 프로바이더 삭제: ${providerName}`);
return res.json({
success: true,
data: {
message: 'LLM 프로바이더가 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
// ===== 사용자 관리 =====
/**
* 사용자 목록 조회
*/
exports.getUsers = async (req, res, next) => {
try {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 100;
const offset = (page - 1) * limit;
const { count, rows: users } = await User.findAndCountAll({
attributes: [
'id',
'email',
'name',
'role',
'status',
'plan',
'monthlyTokenLimit',
'lastLoginAt',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
// 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환)
return res.json({
success: true,
data: users,
});
} catch (error) {
return next(error);
}
};
/**
* 사용자 정보 수정
*/
exports.updateUser = async (req, res, next) => {
try {
const { id } = req.params;
const { role, status, plan, monthlyTokenLimit } = req.body;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
if (role) user.role = role;
if (status) user.status = status;
if (plan) user.plan = plan;
if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit;
await user.save();
logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
// ===== 시스템 통계 =====
/**
* 사용자별 사용량 통계
*/
exports.getUsageByUser = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 사용자별 집계 (raw SQL 사용)
const userStats = await UsageLog.sequelize.query(`
SELECT
u.id as "userId",
u.email,
u.name,
COALESCE(SUM(ul.total_tokens), 0) as "totalTokens",
COALESCE(SUM(ul.cost_usd), 0) as "totalCost",
COUNT(ul.id) as "requestCount"
FROM users u
LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate
GROUP BY u.id, u.email, u.name
HAVING COUNT(ul.id) > 0
ORDER BY SUM(ul.total_tokens) DESC NULLS LAST
`, {
replacements: { startDate },
type: UsageLog.sequelize.QueryTypes.SELECT,
});
// 데이터 정리
const result = userStats.map((stat) => ({
userId: stat.userId,
email: stat.email || 'Unknown',
name: stat.name || '',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 프로바이더별 사용량 통계
*/
exports.getUsageByProvider = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 프로바이더별 집계 (컬럼명 수정: providerName, modelName)
const providerStats = await UsageLog.findAll({
where: {
createdAt: { [Op.gte]: startDate },
},
attributes: [
'providerName',
'modelName',
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
[UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'],
],
group: ['providerName', 'modelName'],
order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']],
raw: true,
});
// 데이터 정리
const result = providerStats.map((stat) => ({
provider: stat.providerName || 'Unknown',
model: stat.modelName || 'Unknown',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
promptTokens: parseInt(stat.promptTokens, 10) || 0,
completionTokens: parseInt(stat.completionTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0),
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 시스템 통계 조회
*/
exports.getStats = async (req, res, next) => {
try {
// 전체 사용자 수
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { status: 'active' } });
// 전체 API 키 수
const totalApiKeys = await ApiKey.count();
const activeApiKeys = await ApiKey.count({ where: { status: 'active' } });
// 오늘 사용량
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: today },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 이번 달 사용량
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthlyUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: monthStart },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 활성 프로바이더 수
const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } });
return res.json({
success: true,
data: {
users: {
total: totalUsers,
active: activeUsers,
},
apiKeys: {
total: totalApiKeys,
active: activeApiKeys,
},
providers: {
active: activeProviders,
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: {
tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0,
cost: parseFloat(monthlyUsage?.totalCost) || 0,
requests: parseInt(monthlyUsage?.requestCount, 10) || 0,
},
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,215 +0,0 @@
// src/controllers/api-key.controller.js
// API 키 컨트롤러
const { ApiKey } = require('../models');
const logger = require('../config/logger.config');
/**
* API 키 발급
*/
exports.create = async (req, res, next) => {
try {
const { name, expiresInDays, permissions } = req.body;
const userId = req.user.userId;
// API 키 생성
const rawKey = ApiKey.generateKey();
const keyHash = ApiKey.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, 12);
// 만료 일시 계산
let expiresAt = null;
if (expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
}
const apiKey = await ApiKey.create({
userId,
name,
keyPrefix,
keyHash,
permissions: permissions || ['chat:read', 'chat:write'],
expiresAt,
});
logger.info(`API 키 발급: ${name} (user: ${userId})`);
// 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가)
return res.status(201).json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
key: rawKey, // 원본 키 (한 번만 표시)
keyPrefix: apiKey.keyPrefix,
permissions: apiKey.permissions,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.',
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const userId = req.user.userId;
const apiKeys = await ApiKey.findAll({
where: { userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
],
order: [['createdAt', 'DESC']],
});
return res.json({
success: true,
data: apiKeys,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 상세 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
'updatedAt',
],
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
return res.json({
success: true,
data: apiKey,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 수정
*/
exports.update = async (req, res, next) => {
try {
const { id } = req.params;
const { name, status } = req.body;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
if (name) apiKey.name = name;
if (status) apiKey.status = status;
await apiKey.save();
logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
status: apiKey.status,
updatedAt: apiKey.updatedAt,
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 폐기
*/
exports.revoke = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
apiKey.status = 'revoked';
await apiKey.save();
logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
message: 'API 키가 폐기되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,195 +0,0 @@
// src/controllers/auth.controller.js
// 인증 컨트롤러
const jwt = require('jsonwebtoken');
const { User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
/**
* JWT 토큰 생성
*/
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId: user.id },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
/**
* 회원가입
*/
exports.register = async (req, res, next) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(409).json({
success: false,
error: {
code: 'EMAIL_ALREADY_EXISTS',
message: '이미 등록된 이메일입니다.',
},
});
}
// 사용자 생성
const user = await User.create({
email,
password,
name,
});
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`새 사용자 가입: ${email}`);
return res.status(201).json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 로그인
*/
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 비밀번호 검증
const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 계정 상태 확인
if (user.status !== 'active') {
return res.status(403).json({
success: false,
error: {
code: 'ACCOUNT_INACTIVE',
message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.',
},
});
}
// 마지막 로그인 시간 업데이트
user.lastLoginAt = new Date();
await user.save();
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`사용자 로그인: ${email}`);
return res.json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 토큰 갱신
*/
exports.refresh = async (req, res, next) => {
try {
const { refreshToken } = req.body;
// 리프레시 토큰 검증
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
} catch (error) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_REFRESH_TOKEN',
message: '유효하지 않은 리프레시 토큰입니다.',
},
});
}
// 사용자 조회
const user = await User.findByPk(decoded.userId);
if (!user || user.status !== 'active') {
return res.status(401).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 새 토큰 생성
const tokens = generateTokens(user);
return res.json({
success: true,
data: tokens,
});
} catch (error) {
return next(error);
}
};
/**
* 로그아웃
*/
exports.logout = async (req, res) => {
// 클라이언트에서 토큰 삭제 처리
// 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현)
return res.json({
success: true,
data: {
message: '로그아웃되었습니다.',
},
});
};
@@ -1,152 +0,0 @@
// src/controllers/chat.controller.js
// 채팅 컨트롤러 (OpenAI 호환 API)
const llmService = require('../services/llm.service');
const logger = require('../config/logger.config');
/**
* 채팅 완성 API (OpenAI 호환)
* POST /api/v1/chat/completions
*/
exports.completions = async (req, res, next) => {
try {
const {
model = 'gemini-2.0-flash',
messages,
temperature = 0.7,
max_tokens = 4096,
stream = false,
} = req.body;
const startTime = Date.now();
// 스트리밍 응답 처리
if (stream) {
return handleStreamingResponse(req, res, {
model,
messages,
temperature,
maxTokens: max_tokens,
});
}
// 일반 응답 처리
const result = await llmService.chat({
model,
messages,
temperature,
maxTokens: max_tokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
const responseTime = Date.now() - startTime;
// 사용량 정보 저장 (미들웨어에서 처리)
req.usageData = {
providerId: result.providerId,
providerName: result.provider,
modelName: result.model,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
costUsd: result.cost,
responseTimeMs: responseTime,
success: true,
};
// OpenAI 호환 응답 형식
return res.json({
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: result.model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: result.text,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: result.usage.promptTokens,
completion_tokens: result.usage.completionTokens,
total_tokens: result.usage.totalTokens,
},
});
} catch (error) {
logger.error('채팅 완성 오류:', error);
// 사용량 정보 저장 (실패)
req.usageData = {
success: false,
errorMessage: error.message,
};
return next(error);
}
};
/**
* 스트리밍 응답 처리
*/
async function handleStreamingResponse(req, res, params) {
const { model, messages, temperature, maxTokens } = params;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 스트리밍 응답 생성
const stream = await llmService.chatStream({
model,
messages,
temperature,
maxTokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
// 스트림 이벤트 처리
for await (const chunk of stream) {
const data = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
content: chunk.text,
},
finish_reason: chunk.done ? 'stop' : null,
},
],
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// 스트림 종료
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
logger.error('스트리밍 오류:', error);
const errorData = {
error: {
message: error.message,
type: 'server_error',
},
};
res.write(`data: ${JSON.stringify(errorData)}\n\n`);
res.end();
}
}
@@ -1,67 +0,0 @@
// src/controllers/model.controller.js
// 모델 컨트롤러
const { LLMProvider } = require('../models');
/**
* 사용 가능한 모델 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const providers = await LLMProvider.getActiveProviders();
// OpenAI 호환 형식으로 변환
const models = providers.map((provider) => ({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
}));
return res.json({
object: 'list',
data: models,
});
} catch (error) {
return next(error);
}
};
/**
* 모델 상세 정보 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findOne({
where: { modelName: id, isActive: true },
});
if (!provider) {
return res.status(404).json({
error: {
message: `모델 '${id}'을(를) 찾을 수 없습니다.`,
type: 'invalid_request_error',
param: 'model',
code: 'model_not_found',
},
});
}
return res.json({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
});
} catch (error) {
return next(error);
}
};
@@ -1,177 +0,0 @@
// src/controllers/usage.controller.js
// 사용량 컨트롤러
const { UsageLog, User } = require('../models');
const { Op } = require('sequelize');
/**
* 사용량 요약 조회
*/
exports.getSummary = async (req, res, next) => {
try {
const userId = req.user.userId;
// 사용자 정보 조회
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
userId,
now.getFullYear(),
now.getMonth() + 1
);
// 오늘 사용량
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
const todayUsage = await UsageLog.findOne({
where: {
userId,
createdAt: {
[Op.between]: [todayStart, todayEnd],
},
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return res.json({
success: true,
data: {
plan: user.plan,
limit: {
monthly: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: monthlyUsage,
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 일별 사용량 조회
*/
exports.getDailyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const { startDate, endDate } = req.query;
// 기본값: 최근 30일
const end = endDate ? new Date(endDate) : new Date();
const start = startDate ? new Date(startDate) : new Date(end);
if (!startDate) {
start.setDate(start.getDate() - 30);
}
const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end);
return res.json({
success: true,
data: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
usage: dailyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 월별 사용량 조회
*/
exports.getMonthlyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const now = new Date();
const year = parseInt(req.query.year, 10) || now.getFullYear();
const month = parseInt(req.query.month, 10) || (now.getMonth() + 1);
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month);
return res.json({
success: true,
data: {
year,
month,
usage: monthlyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 사용량 로그 목록 조회
*/
exports.getLogs = async (req, res, next) => {
try {
const userId = req.user.userId;
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
const { count, rows: logs } = await UsageLog.findAndCountAll({
where: { userId },
attributes: [
'id',
'providerName',
'modelName',
'promptTokens',
'completionTokens',
'totalTokens',
'costUsd',
'responseTimeMs',
'success',
'errorMessage',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
return res.json({
success: true,
data: {
logs,
pagination: {
total: count,
page,
limit,
totalPages: Math.ceil(count / limit),
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,113 +0,0 @@
// src/controllers/user.controller.js
// 사용자 컨트롤러
const { User, UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 내 정보 조회
*/
exports.getMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량 조회
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
user.id,
now.getFullYear(),
now.getMonth() + 1
);
return res.json({
success: true,
data: {
...user.toSafeJSON(),
usage: {
monthly: monthlyUsage,
limit: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 내 정보 수정
*/
exports.updateMe = async (req, res, next) => {
try {
const { name, password } = req.body;
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 업데이트할 필드만 설정
if (name) user.name = name;
if (password) user.password = password;
await user.save();
logger.info(`사용자 정보 수정: ${user.email}`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
/**
* 계정 삭제
*/
exports.deleteMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 소프트 삭제 (상태 변경)
user.status = 'inactive';
await user.save();
logger.info(`사용자 계정 삭제: ${user.email}`);
return res.json({
success: true,
data: {
message: '계정이 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,257 +0,0 @@
// src/middlewares/auth.middleware.js
// 인증 미들웨어
const jwt = require('jsonwebtoken');
const { ApiKey, User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* JWT 토큰 인증 미들웨어
* Authorization: Bearer <JWT_TOKEN>
*/
exports.authenticateJWT = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증 토큰이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
/**
* API 키 인증 미들웨어
* Authorization: Bearer <API_KEY>
*/
exports.authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
message: 'API 키가 필요합니다.',
type: 'invalid_request_error',
code: 'missing_api_key',
},
});
}
const apiKeyValue = authHeader.substring(7);
// API 키 접두사 확인
const prefix = process.env.API_KEY_PREFIX || 'sk-';
if (!apiKeyValue.startsWith(prefix)) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키 형식입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// API 키 조회
const apiKey = await ApiKey.findByKey(apiKeyValue);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// 만료 확인
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
// 사용자 상태 확인
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
// 사용 기록 업데이트
await apiKey.recordUsage();
// 요청 객체에 사용자 및 API 키 정보 추가
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
} catch (error) {
logger.error('API 키 인증 오류:', error);
return next(error);
}
};
/**
* 관리자 권한 확인 미들웨어
*/
exports.requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: {
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다.',
},
});
}
return next();
};
/**
* JWT 또는 API 키 인증 미들웨어
* JWT 토큰과 API 키 모두 허용
*/
exports.authenticateAny = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
const prefix = process.env.API_KEY_PREFIX || 'sk-';
// API 키인 경우
if (token.startsWith(prefix)) {
const apiKey = await ApiKey.findByKey(token);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
await apiKey.recordUsage();
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
}
// JWT 토큰인 경우
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
@@ -1,80 +0,0 @@
// src/middlewares/error-handler.middleware.js
// 에러 핸들러 미들웨어
const logger = require('../config/logger.config');
/**
* 전역 에러 핸들러
*/
module.exports = (err, req, res, _next) => {
// 에러 로깅
logger.error('에러 발생:', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Sequelize 유효성 검사 에러
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '데이터 유효성 검사 실패',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// Sequelize 고유 제약 조건 에러
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({
success: false,
error: {
code: 'DUPLICATE_ENTRY',
message: '중복된 데이터가 존재합니다.',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// JWT 에러
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
// 기본 에러 응답
const statusCode = err.statusCode || 500;
const message = err.message || '서버 오류가 발생했습니다.';
// 프로덕션 환경에서는 상세 에러 숨김
const response = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' && statusCode === 500
? '서버 오류가 발생했습니다.'
: message,
},
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
return res.status(statusCode).json(response);
};
@@ -1,50 +0,0 @@
// src/middlewares/usage-logger.middleware.js
// 사용량 로깅 미들웨어
const { UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 사용량 로깅 미들웨어
* 응답 완료 후 사용량 정보를 데이터베이스에 저장
*/
exports.usageLogger = (req, res, next) => {
// 응답 완료 후 처리
res.on('finish', async () => {
try {
// 사용량 데이터가 없으면 스킵
if (!req.usageData) {
return;
}
const usageData = {
userId: req.user?.id || req.user?.userId,
apiKeyId: req.apiKey?.id || null,
providerId: req.usageData.providerId || null,
providerName: req.usageData.providerName || null,
modelName: req.usageData.modelName || null,
promptTokens: req.usageData.promptTokens || 0,
completionTokens: req.usageData.completionTokens || 0,
totalTokens: req.usageData.totalTokens || 0,
costUsd: req.usageData.costUsd || 0,
responseTimeMs: req.usageData.responseTimeMs || null,
success: req.usageData.success !== false,
errorMessage: req.usageData.errorMessage || null,
requestIp: req.ip || req.connection?.remoteAddress,
userAgent: req.headers['user-agent'] || null,
};
await UsageLog.create(usageData);
logger.debug('사용량 로그 저장:', {
userId: usageData.userId,
tokens: usageData.totalTokens,
cost: usageData.costUsd,
});
} catch (error) {
logger.error('사용량 로그 저장 실패:', error);
}
});
next();
};
@@ -1,30 +0,0 @@
// src/middlewares/validation.middleware.js
// 유효성 검사 미들웨어
const { validationResult } = require('express-validator');
/**
* 요청 유효성 검사 결과 처리
*/
exports.validateRequest = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const formattedErrors = errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
}));
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '입력값이 올바르지 않습니다.',
details: formattedErrors,
},
});
}
return next();
};
-130
View File
@@ -1,130 +0,0 @@
// src/models/api-key.model.js
// API 키 모델
const { DataTypes } = require('sequelize');
const crypto = require('crypto');
module.exports = (sequelize) => {
const ApiKey = sequelize.define('ApiKey', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '소유자 사용자 ID',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'API 키 이름 (사용자 지정)',
},
keyPrefix: {
type: DataTypes.STRING(12),
allowNull: false,
comment: 'API 키 접두사 (표시용)',
},
keyHash: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
comment: 'API 키 해시 (SHA-256)',
},
permissions: {
type: DataTypes.JSONB,
defaultValue: ['chat:read', 'chat:write'],
comment: '권한 목록',
},
rateLimit: {
type: DataTypes.INTEGER,
defaultValue: 60, // 분당 60회
comment: '분당 요청 제한',
},
status: {
type: DataTypes.ENUM('active', 'revoked', 'expired'),
defaultValue: 'active',
comment: 'API 키 상태',
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '만료 일시 (null이면 무기한)',
},
lastUsedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 사용 시간',
},
totalRequests: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 요청 수',
},
}, {
tableName: 'api_keys',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['key_hash'],
unique: true,
},
{
fields: ['user_id'],
},
{
fields: ['status'],
},
],
});
// 클래스 메서드: API 키 생성
ApiKey.generateKey = function() {
const prefix = process.env.API_KEY_PREFIX || 'sk-';
const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48;
const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length);
return `${prefix}${randomPart}`;
};
// 클래스 메서드: API 키 해시 생성
ApiKey.hashKey = function(key) {
return crypto.createHash('sha256').update(key).digest('hex');
};
// 클래스 메서드: API 키로 조회
ApiKey.findByKey = async function(key) {
const keyHash = this.hashKey(key);
const apiKey = await this.findOne({
where: { keyHash, status: 'active' },
});
if (apiKey) {
// 사용자 정보 별도 조회
const { User } = require('./index');
apiKey.user = await User.findByPk(apiKey.userId);
}
return apiKey;
};
// 인스턴스 메서드: 사용 기록 업데이트
ApiKey.prototype.recordUsage = async function() {
this.lastUsedAt = new Date();
this.totalRequests += 1;
await this.save();
};
// 인스턴스 메서드: 만료 여부 확인
ApiKey.prototype.isExpired = function() {
if (!this.expiresAt) return false;
return new Date() > this.expiresAt;
};
return ApiKey;
};
-55
View File
@@ -1,55 +0,0 @@
// src/models/index.js
// Sequelize 모델 인덱스
const { Sequelize } = require('sequelize');
const config = require('../config/database.config');
const env = process.env.NODE_ENV || 'development';
const dbConfig = config[env];
// Sequelize 인스턴스 생성
const sequelize = new Sequelize(
dbConfig.database,
dbConfig.username,
dbConfig.password,
{
host: dbConfig.host,
port: dbConfig.port,
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
dialectOptions: dbConfig.dialectOptions,
}
);
// 모델 임포트
const User = require('./user.model')(sequelize);
const ApiKey = require('./api-key.model')(sequelize);
const UsageLog = require('./usage-log.model')(sequelize);
const LLMProvider = require('./llm-provider.model')(sequelize);
// 관계 설정
// User - ApiKey (1:N)
User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' });
ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// User - UsageLog (1:N)
User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' });
UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// ApiKey - UsageLog (1:N)
ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' });
UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' });
// LLMProvider - UsageLog (1:N)
LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' });
UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' });
module.exports = {
sequelize,
Sequelize,
User,
ApiKey,
UsageLog,
LLMProvider,
};
@@ -1,143 +0,0 @@
// src/models/llm-provider.model.js
// LLM 프로바이더 모델
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const LLMProvider = sequelize.define('LLMProvider', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
comment: '프로바이더 이름 (gemini, openai, claude 등)',
},
displayName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '표시 이름',
},
endpoint: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'API 엔드포인트 URL',
},
apiKey: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'API 키 (암호화 저장 권장)',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '기본 모델 이름',
},
priority: {
type: DataTypes.INTEGER,
defaultValue: 100,
comment: '우선순위 (낮을수록 우선)',
},
maxTokens: {
type: DataTypes.INTEGER,
defaultValue: 4096,
comment: '최대 토큰 수',
},
temperature: {
type: DataTypes.FLOAT,
defaultValue: 0.7,
comment: '기본 온도',
},
timeoutMs: {
type: DataTypes.INTEGER,
defaultValue: 60000,
comment: '타임아웃 (밀리초)',
},
costPer1kInputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '입력 토큰 1K당 비용 (USD)',
},
costPer1kOutputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '출력 토큰 1K당 비용 (USD)',
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '활성화 여부',
},
isHealthy: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '건강 상태',
},
lastHealthCheck: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 헬스 체크 시간',
},
healthCheckUrl: {
type: DataTypes.STRING(500),
allowNull: true,
comment: '헬스 체크 URL',
},
config: {
type: DataTypes.JSONB,
defaultValue: {},
comment: '추가 설정',
},
}, {
tableName: 'llm_providers',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['name'],
unique: true,
},
{
fields: ['priority'],
},
{
fields: ['is_active', 'is_healthy'],
},
],
});
// 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순)
LLMProvider.getActiveProviders = async function() {
return this.findAll({
where: { isActive: true },
order: [['priority', 'ASC']],
});
};
// 클래스 메서드: 건강한 프로바이더 목록 조회
LLMProvider.getHealthyProviders = async function() {
return this.findAll({
where: { isActive: true, isHealthy: true },
order: [['priority', 'ASC']],
});
};
// 인스턴스 메서드: 헬스 상태 업데이트
LLMProvider.prototype.updateHealth = async function(isHealthy) {
this.isHealthy = isHealthy;
this.lastHealthCheck = new Date();
await this.save();
};
// 인스턴스 메서드: 비용 계산
LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) {
const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0);
const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0);
return inputCost + outputCost;
};
return LLMProvider;
};
-164
View File
@@ -1,164 +0,0 @@
// src/models/usage-log.model.js
// 사용량 로그 모델
const { DataTypes, Op } = require('sequelize');
module.exports = (sequelize) => {
const UsageLog = sequelize.define('UsageLog', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '사용자 ID',
},
apiKeyId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'api_keys',
key: 'id',
},
comment: 'API 키 ID',
},
providerId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'llm_providers',
key: 'id',
},
comment: 'LLM 프로바이더 ID',
},
providerName: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'LLM 프로바이더 이름',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '사용된 모델 이름',
},
promptTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '프롬프트 토큰 수',
},
completionTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '완성 토큰 수',
},
totalTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 토큰 수',
},
costUsd: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '비용 (USD)',
},
responseTimeMs: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '응답 시간 (밀리초)',
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '성공 여부',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: '에러 메시지',
},
requestIp: {
type: DataTypes.STRING(45),
allowNull: true,
comment: '요청 IP 주소',
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User-Agent',
},
}, {
tableName: 'usage_logs',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['user_id'],
},
{
fields: ['api_key_id'],
},
{
fields: ['created_at'],
},
{
fields: ['provider_name'],
},
],
});
// 클래스 메서드: 사용자별 일별 사용량 조회
UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) {
return this.findAll({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('DATE', sequelize.col('created_at')), 'date'],
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
group: [sequelize.fn('DATE', sequelize.col('created_at'))],
order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']],
raw: true,
});
};
// 클래스 메서드: 사용자별 월간 총 사용량 조회
UsageLog.getMonthlyTotalByUser = async function(userId, year, month) {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59);
const result = await this.findOne({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return {
totalTokens: parseInt(result.totalTokens, 10) || 0,
totalCost: parseFloat(result.totalCost) || 0,
requestCount: parseInt(result.requestCount, 10) || 0,
};
};
return UsageLog;
};
-92
View File
@@ -1,92 +0,0 @@
// src/models/user.model.js
// 사용자 모델
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
comment: '이메일 (로그인 ID)',
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
comment: '비밀번호 (해시)',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '사용자 이름',
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
comment: '역할 (user: 일반 사용자, admin: 관리자)',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
defaultValue: 'active',
comment: '계정 상태',
},
plan: {
type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'),
defaultValue: 'free',
comment: '요금제 플랜',
},
monthlyTokenLimit: {
type: DataTypes.INTEGER,
defaultValue: 100000, // 무료 플랜 기본 10만 토큰
comment: '월간 토큰 한도',
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 로그인 시간',
},
}, {
tableName: 'users',
timestamps: true,
underscored: true,
hooks: {
// 비밀번호 해싱
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
},
});
// 인스턴스 메서드: 비밀번호 검증
User.prototype.validatePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
// 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외)
User.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.password;
return values;
};
return User;
};
-151
View File
@@ -1,151 +0,0 @@
// src/routes/admin.routes.js
// 관리자 라우트
const express = require('express');
const { body, param } = require('express-validator');
const adminController = require('../controllers/admin.controller');
const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 + 관리자 권한 필요
router.use(authenticateJWT);
router.use(requireAdmin);
// ===== LLM 프로바이더 관리 =====
/**
* GET /api/v1/admin/providers
* LLM 프로바이더 목록 조회
*/
router.get('/providers', adminController.getProviders);
/**
* POST /api/v1/admin/providers
* LLM 프로바이더 추가
*/
router.post(
'/providers',
[
body('name')
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('프로바이더 이름은 1-50자 사이여야 합니다'),
body('displayName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('표시 이름은 1-100자 사이여야 합니다'),
body('modelName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('모델 이름은 1-100자 사이여야 합니다'),
body('apiKey')
.optional()
.isString(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.createProvider
);
/**
* PATCH /api/v1/admin/providers/:id
* LLM 프로바이더 수정 (API 키 설정 포함)
*/
router.patch(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
body('apiKey')
.optional()
.isString(),
body('modelName')
.optional()
.isString(),
body('isActive')
.optional()
.isBoolean(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.updateProvider
);
/**
* DELETE /api/v1/admin/providers/:id
* LLM 프로바이더 삭제
*/
router.delete(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
validateRequest,
],
adminController.deleteProvider
);
// ===== 사용자 관리 =====
/**
* GET /api/v1/admin/users
* 사용자 목록 조회
*/
router.get('/users', adminController.getUsers);
/**
* PATCH /api/v1/admin/users/:id
* 사용자 정보 수정 (역할, 상태, 플랜 등)
*/
router.patch(
'/users/:id',
[
param('id')
.isUUID()
.withMessage('유효한 사용자 ID가 아닙니다'),
body('role')
.optional()
.isIn(['user', 'admin']),
body('status')
.optional()
.isIn(['active', 'inactive', 'suspended']),
body('plan')
.optional()
.isIn(['free', 'basic', 'pro', 'enterprise']),
body('monthlyTokenLimit')
.optional()
.isInt({ min: 0 }),
validateRequest,
],
adminController.updateUser
);
// ===== 시스템 통계 =====
/**
* GET /api/v1/admin/stats
* 시스템 통계 조회
*/
router.get('/stats', adminController.getStats);
/**
* GET /api/v1/admin/usage/by-user
* 사용자별 사용량 통계
*/
router.get('/usage/by-user', adminController.getUsageByUser);
/**
* GET /api/v1/admin/usage/by-provider
* 프로바이더별 사용량 통계
*/
router.get('/usage/by-provider', adminController.getUsageByProvider);
module.exports = router;
-99
View File
@@ -1,99 +0,0 @@
// src/routes/api-key.routes.js
// API 키 라우트
const express = require('express');
const { body, param } = require('express-validator');
const apiKeyController = require('../controllers/api-key.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* POST /api/v1/api-keys
* API 키 발급
*/
router.post(
'/',
[
body('name')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('expiresInDays')
.optional()
.isInt({ min: 1, max: 365 })
.withMessage('만료 기간은 1-365일 사이여야 합니다'),
body('permissions')
.optional()
.isArray()
.withMessage('권한은 배열이어야 합니다'),
validateRequest,
],
apiKeyController.create
);
/**
* GET /api/v1/api-keys
* API 키 목록 조회
*/
router.get('/', apiKeyController.list);
/**
* GET /api/v1/api-keys/:id
* API 키 상세 조회
*/
router.get(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.get
);
/**
* PATCH /api/v1/api-keys/:id
* API 키 수정
*/
router.patch(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
body('name')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('status')
.optional()
.isIn(['active', 'revoked'])
.withMessage('상태는 active 또는 revoked여야 합니다'),
validateRequest,
],
apiKeyController.update
);
/**
* DELETE /api/v1/api-keys/:id
* API 키 폐기
*/
router.delete(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.revoke
);
module.exports = router;
-76
View File
@@ -1,76 +0,0 @@
// src/routes/auth.routes.js
// 인증 라우트
const express = require('express');
const { body } = require('express-validator');
const authController = require('../controllers/auth.controller');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
/**
* POST /api/v1/auth/register
* 회원가입
*/
router.post(
'/register',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
body('name')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
validateRequest,
],
authController.register
);
/**
* POST /api/v1/auth/login
* 로그인
*/
router.post(
'/login',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.notEmpty()
.withMessage('비밀번호를 입력해주세요'),
validateRequest,
],
authController.login
);
/**
* POST /api/v1/auth/refresh
* 토큰 갱신
*/
router.post(
'/refresh',
[
body('refreshToken')
.notEmpty()
.withMessage('리프레시 토큰을 입력해주세요'),
validateRequest,
],
authController.refresh
);
/**
* POST /api/v1/auth/logout
* 로그아웃
*/
router.post('/logout', authController.logout);
module.exports = router;
-55
View File
@@ -1,55 +0,0 @@
// src/routes/chat.routes.js
// 채팅 API 라우트 (OpenAI 호환)
const express = require('express');
const { body } = require('express-validator');
const chatController = require('../controllers/chat.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const { usageLogger } = require('../middlewares/usage-logger.middleware');
const router = express.Router();
/**
* POST /api/v1/chat/completions
* 채팅 완성 API (OpenAI 호환)
*
* 인증: Bearer API_KEY 또는 JWT 토큰
*/
router.post(
'/completions',
authenticateAny,
[
body('model')
.optional()
.isString()
.withMessage('모델은 문자열이어야 합니다'),
body('messages')
.isArray({ min: 1 })
.withMessage('메시지 배열이 필요합니다'),
body('messages.*.role')
.isIn(['system', 'user', 'assistant'])
.withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'),
body('messages.*.content')
.isString()
.notEmpty()
.withMessage('메시지 내용이 필요합니다'),
body('temperature')
.optional()
.isFloat({ min: 0, max: 2 })
.withMessage('온도는 0-2 사이여야 합니다'),
body('max_tokens')
.optional()
.isInt({ min: 1, max: 128000 })
.withMessage('최대 토큰은 1-128000 사이여야 합니다'),
body('stream')
.optional()
.isBoolean()
.withMessage('스트림은 불리언이어야 합니다'),
validateRequest,
],
usageLogger,
chatController.completions
);
module.exports = router;
-45
View File
@@ -1,45 +0,0 @@
// src/routes/index.js
// API 라우트 인덱스
const express = require('express');
const authRoutes = require('./auth.routes');
const userRoutes = require('./user.routes');
const apiKeyRoutes = require('./api-key.routes');
const chatRoutes = require('./chat.routes');
const usageRoutes = require('./usage.routes');
const modelRoutes = require('./model.routes');
const adminRoutes = require('./admin.routes');
const router = express.Router();
// API 정보
router.get('/', (req, res) => {
res.json({
success: true,
data: {
name: 'AI Assistant API',
version: '1.0.0',
description: 'LLM API Platform - OpenAI 호환 API',
endpoints: {
auth: '/api/v1/auth',
users: '/api/v1/users',
apiKeys: '/api/v1/api-keys',
chat: '/api/v1/chat',
models: '/api/v1/models',
usage: '/api/v1/usage',
},
documentation: 'https://docs.example.com',
},
});
});
// 라우트 등록
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/api-keys', apiKeyRoutes);
router.use('/chat', chatRoutes);
router.use('/models', modelRoutes);
router.use('/usage', usageRoutes);
router.use('/admin', adminRoutes);
module.exports = router;
-24
View File
@@ -1,24 +0,0 @@
// src/routes/model.routes.js
// 모델 라우트
const express = require('express');
const modelController = require('../controllers/model.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const router = express.Router();
/**
* GET /api/v1/models
* 사용 가능한 모델 목록 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/', authenticateAny, modelController.list);
/**
* GET /api/v1/models/:id
* 모델 상세 정보 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/:id', authenticateAny, modelController.get);
module.exports = router;
-81
View File
@@ -1,81 +0,0 @@
// src/routes/usage.routes.js
// 사용량 라우트
const express = require('express');
const { query } = require('express-validator');
const usageController = require('../controllers/usage.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/usage
* 사용량 요약 조회
*/
router.get('/', usageController.getSummary);
/**
* GET /api/v1/usage/daily
* 일별 사용량 조회
*/
router.get(
'/daily',
[
query('startDate')
.optional()
.isISO8601()
.withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'),
query('endDate')
.optional()
.isISO8601()
.withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'),
validateRequest,
],
usageController.getDailyUsage
);
/**
* GET /api/v1/usage/monthly
* 월별 사용량 조회
*/
router.get(
'/monthly',
[
query('year')
.optional()
.isInt({ min: 2020, max: 2100 })
.withMessage('연도는 2020-2100 사이여야 합니다'),
query('month')
.optional()
.isInt({ min: 1, max: 12 })
.withMessage('월은 1-12 사이여야 합니다'),
validateRequest,
],
usageController.getMonthlyUsage
);
/**
* GET /api/v1/usage/logs
* 사용량 로그 목록 조회
*/
router.get(
'/logs',
[
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('페이지는 1 이상이어야 합니다'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('한도는 1-100 사이여야 합니다'),
validateRequest,
],
usageController.getLogs
);
module.exports = router;
-50
View File
@@ -1,50 +0,0 @@
// src/routes/user.routes.js
// 사용자 라우트
const express = require('express');
const { body } = require('express-validator');
const userController = require('../controllers/user.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/users/me
* 내 정보 조회
*/
router.get('/me', userController.getMe);
/**
* PATCH /api/v1/users/me
* 내 정보 수정
*/
router.patch(
'/me',
[
body('name')
.optional()
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
validateRequest,
],
userController.updateMe
);
/**
* DELETE /api/v1/users/me
* 계정 삭제
*/
router.delete('/me', userController.deleteMe);
module.exports = router;
@@ -1,74 +0,0 @@
// src/seeders/001-llm-providers.js
// LLM 프로바이더 시드 데이터
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('llm_providers', [
{
id: uuidv4(),
name: 'gemini',
display_name: 'Google Gemini',
endpoint: null, // SDK 사용
api_key: process.env.GEMINI_API_KEY || '',
model_name: 'gemini-2.0-flash',
priority: 1,
max_tokens: 8192,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.001,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'openai',
display_name: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
api_key: process.env.OPENAI_API_KEY || '',
model_name: 'gpt-4o-mini',
priority: 2,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00015,
cost_per_1k_output_tokens: 0.0006,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'claude',
display_name: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
api_key: process.env.CLAUDE_API_KEY || '',
model_name: 'claude-3-haiku-20240307',
priority: 3,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.00125,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('llm_providers', null, {});
},
};
-128
View File
@@ -1,128 +0,0 @@
// src/services/init.service.js
// 초기 데이터 설정 서비스
const { User, LLMProvider } = require('../models');
const logger = require('../config/logger.config');
/**
* 초기 관리자 계정 생성
*/
async function createDefaultAdmin() {
try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!';
const existing = await User.findOne({ where: { email: adminEmail } });
if (existing) {
logger.info(`관리자 계정 이미 존재: ${adminEmail}`);
return existing;
}
const admin = await User.create({
email: adminEmail,
password: adminPassword,
name: '관리자',
role: 'admin',
status: 'active',
plan: 'enterprise',
monthlyTokenLimit: 10000000, // 1000만 토큰
});
logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`);
return admin;
} catch (error) {
logger.error('관리자 계정 생성 실패:', error);
throw error;
}
}
/**
* 기본 LLM 프로바이더 생성
*/
async function createDefaultProviders() {
try {
const providers = [
{
name: 'gemini',
displayName: 'Google Gemini',
endpoint: null,
apiKey: process.env.GEMINI_API_KEY || '',
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
},
{
name: 'openai',
displayName: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY || '',
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
},
{
name: 'claude',
displayName: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY || '',
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
},
];
for (const providerData of providers) {
const existing = await LLMProvider.findOne({ where: { name: providerData.name } });
if (existing) {
// API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트
if (providerData.apiKey && !existing.apiKey) {
existing.apiKey = providerData.apiKey;
existing.modelName = providerData.modelName;
await existing.save();
logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`);
}
continue;
}
await LLMProvider.create({
...providerData,
isActive: true,
isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy
});
logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`);
}
} catch (error) {
logger.error('LLM 프로바이더 생성 실패:', error);
throw error;
}
}
/**
* 초기화 실행
*/
async function initialize() {
logger.info('🔧 초기 데이터 설정 시작...');
await createDefaultAdmin();
await createDefaultProviders();
logger.info('✅ 초기 데이터 설정 완료');
}
module.exports = {
initialize,
createDefaultAdmin,
createDefaultProviders,
};
-385
View File
@@ -1,385 +0,0 @@
// src/services/llm.service.js
// LLM 서비스 - 멀티 프로바이더 지원
const axios = require('axios');
const { LLMProvider } = require('../models');
const logger = require('../config/logger.config');
class LLMService {
constructor() {
this.providers = [];
this.initialized = false;
}
/**
* 서비스 초기화
*/
async initialize() {
if (this.initialized) return;
try {
await this.loadProviders();
this.initialized = true;
logger.info('✅ LLM 서비스 초기화 완료');
} catch (error) {
logger.error('❌ LLM 서비스 초기화 실패:', error);
// 초기화 실패 시 기본 프로바이더 사용
this.providers = this.getDefaultProviders();
this.initialized = true;
}
}
/**
* 데이터베이스에서 프로바이더 로드
*/
async loadProviders() {
try {
const providers = await LLMProvider.getHealthyProviders();
if (providers.length === 0) {
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
this.providers = this.getDefaultProviders();
} else {
this.providers = providers.map((p) => ({
id: p.id,
name: p.name,
endpoint: p.endpoint,
apiKey: p.apiKey,
modelName: p.modelName,
priority: p.priority,
maxTokens: p.maxTokens,
temperature: p.temperature,
timeoutMs: p.timeoutMs,
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
isHealthy: p.isHealthy,
config: p.config,
}));
}
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
} catch (error) {
logger.error('프로바이더 로드 실패:', error);
throw error;
}
}
/**
* 기본 프로바이더 설정 (환경 변수 기반)
*/
getDefaultProviders() {
const providers = [];
// Gemini
if (process.env.GEMINI_API_KEY) {
providers.push({
id: 'default-gemini',
name: 'gemini',
apiKey: process.env.GEMINI_API_KEY,
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
isHealthy: true,
});
}
// OpenAI
if (process.env.OPENAI_API_KEY) {
providers.push({
id: 'default-openai',
name: 'openai',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
isHealthy: true,
});
}
// Claude
if (process.env.CLAUDE_API_KEY) {
providers.push({
id: 'default-claude',
name: 'claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY,
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
isHealthy: true,
});
}
return providers;
}
/**
* 채팅 API 호출 (자동 fallback)
*/
async chat(params) {
const {
model,
messages,
temperature = 0.7,
maxTokens = 4096,
userId,
apiKeyId,
} = params;
// 초기화 확인
if (!this.initialized) {
await this.initialize();
}
const startTime = Date.now();
let lastError = null;
// 요청된 모델에 맞는 프로바이더 찾기
const requestedProvider = this.providers.find(
(p) => p.modelName === model || p.name === model
);
// 우선순위 순으로 프로바이더 정렬
const sortedProviders = requestedProvider
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
: this.providers;
// 프로바이더 순회 (fallback)
for (const provider of sortedProviders) {
if (!provider.isHealthy) {
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
continue;
}
try {
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
const result = await this.callProvider(provider, {
messages,
maxTokens: maxTokens || provider.maxTokens,
temperature: temperature || provider.temperature,
});
const responseTime = Date.now() - startTime;
// 비용 계산
const cost = this.calculateCost(
result.usage.promptTokens,
result.usage.completionTokens,
provider.costPer1kInputTokens,
provider.costPer1kOutputTokens
);
logger.info(
`${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
);
return {
text: result.text,
provider: provider.name,
providerId: provider.id,
model: provider.modelName,
usage: result.usage,
responseTime,
cost,
};
} catch (error) {
logger.error(`${provider.name} 실패:`, error.message);
lastError = error;
// 다음 프로바이더로 fallback
continue;
}
}
// 모든 프로바이더 실패
throw new Error(
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
);
}
/**
* 개별 프로바이더 호출
*/
async callProvider(provider, { messages, maxTokens, temperature }) {
const timeout = provider.timeoutMs || 60000;
switch (provider.name) {
case 'gemini':
return this.callGemini(provider, { messages, maxTokens, temperature });
case 'openai':
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
case 'claude':
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
default:
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
}
}
/**
* Gemini API 호출
*/
async callGemini(provider, { messages, maxTokens, temperature }) {
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// system 메시지 처리
const systemMessage = messages.find((m) => m.role === 'system');
const systemInstruction = systemMessage ? systemMessage.content : undefined;
const config = {
maxOutputTokens: maxTokens,
temperature,
};
const result = await ai.models.generateContent({
model: provider.modelName,
contents: contents.filter((c) => c.role !== 'system'),
systemInstruction,
config,
});
// 응답 텍스트 추출
let text = '';
if (result.candidates?.[0]?.content?.parts) {
text = result.candidates[0].content.parts
.filter((p) => p.text)
.map((p) => p.text)
.join('\n');
}
const usage = result.usageMetadata || {};
const promptTokens = usage.promptTokenCount ?? 0;
const completionTokens = usage.candidatesTokenCount ?? 0;
return {
text,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
};
}
/**
* OpenAI API 호출
*/
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
}
);
return {
text: response.data.choices[0].message.content,
usage: {
promptTokens: response.data.usage.prompt_tokens,
completionTokens: response.data.usage.completion_tokens,
totalTokens: response.data.usage.total_tokens,
},
};
}
/**
* Claude API 호출
*/
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
// system 메시지 분리
const systemMessage = messages.find((m) => m.role === 'system');
const otherMessages = messages.filter((m) => m.role !== 'system');
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages: otherMessages,
system: systemMessage?.content,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
}
);
return {
text: response.data.content[0].text,
usage: {
promptTokens: response.data.usage.input_tokens,
completionTokens: response.data.usage.output_tokens,
totalTokens:
response.data.usage.input_tokens + response.data.usage.output_tokens,
},
};
}
/**
* 스트리밍 채팅 (제너레이터)
*/
async *chatStream(params) {
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
const result = await this.chat(params);
// 텍스트를 청크로 분할하여 전송
const chunkSize = 10;
for (let i = 0; i < result.text.length; i += chunkSize) {
yield {
text: result.text.slice(i, i + chunkSize),
done: i + chunkSize >= result.text.length,
};
}
}
/**
* 비용 계산
*/
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
const inputTotal = (promptTokens / 1000) * inputCost;
const outputTotal = (completionTokens / 1000) * outputCost;
return parseFloat((inputTotal + outputTotal).toFixed(6));
}
}
// 싱글톤 인스턴스
const llmService = new LLMService();
module.exports = llmService;
-359
View File
@@ -1,359 +0,0 @@
// src/swagger/api-docs.js
// Swagger API 문서 정의
/**
* @swagger
* /auth/register:
* post:
* tags: [Auth]
* summary: 회원가입
* description: 새 계정을 생성합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password, name]
* properties:
* email:
* type: string
* format: email
* example: user@example.com
* password:
* type: string
* minLength: 8
* example: Password123!
* description: 8자 이상, 영문/숫자/특수문자 포함
* name:
* type: string
* example: 홍길동
* responses:
* 201:
* description: 회원가입 성공
* 400:
* description: 유효성 검사 실패
* 409:
* description: 이미 존재하는 이메일
*/
/**
* @swagger
* /auth/login:
* post:
* tags: [Auth]
* summary: 로그인
* description: 이메일과 비밀번호로 로그인합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email:
* type: string
* format: email
* example: admin@admin.com
* password:
* type: string
* example: Admin123!
* responses:
* 200:
* description: 로그인 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* user:
* type: object
* accessToken:
* type: string
* description: JWT 액세스 토큰
* refreshToken:
* type: string
* description: JWT 리프레시 토큰
* 401:
* description: 인증 실패
*/
/**
* @swagger
* /chat/completions:
* post:
* tags: [Chat]
* summary: 채팅 완성 (OpenAI 호환)
* description: |
* AI 모델에 메시지를 보내고 응답을 받습니다.
* OpenAI API와 호환되는 형식입니다.
*
* **인증**: JWT 토큰 또는 API 키 (sk-xxx)
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionRequest'
* examples:
* simple:
* summary: 간단한 질문
* value:
* model: gemini-2.0-flash
* messages:
* - role: user
* content: 안녕하세요!
* with_system:
* summary: 시스템 프롬프트 포함
* value:
* model: gemini-2.0-flash
* messages:
* - role: system
* content: 당신은 친절한 AI 어시스턴트입니다.
* - role: user
* content: 파이썬으로 Hello World 출력하는 코드 알려줘
* temperature: 0.7
* max_tokens: 1000
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionResponse'
* 401:
* description: 인증 실패
* 429:
* description: 요청 한도 초과
*/
/**
* @swagger
* /models:
* get:
* tags: [Models]
* summary: 모델 목록 조회
* description: 사용 가능한 AI 모델 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: gemini-2.0-flash
* object:
* type: string
* example: model
* owned_by:
* type: string
* example: google
*/
/**
* @swagger
* /api-keys:
* get:
* tags: [API Keys]
* summary: API 키 목록 조회
* description: 발급받은 API 키 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* post:
* tags: [API Keys]
* summary: API 키 발급
* description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: My API Key
* description: API 키 이름
* responses:
* 201:
* description: 발급 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* key:
* type: string
* description: 발급된 API 키 (한 번만 표시)
* example: sk-abc123def456...
*/
/**
* @swagger
* /api-keys/{id}:
* delete:
* tags: [API Keys]
* summary: API 키 폐기
* description: API 키를 폐기합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: API 키 ID
* responses:
* 200:
* description: 폐기 성공
* 404:
* description: API 키를 찾을 수 없음
*/
/**
* @swagger
* /usage:
* get:
* tags: [Usage]
* summary: 사용량 요약 조회
* description: 오늘/이번 달 사용량 요약을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* plan:
* type: string
* example: free
* limit:
* type: object
* properties:
* monthly:
* type: integer
* remaining:
* type: integer
* usage:
* type: object
* properties:
* today:
* type: object
* monthly:
* type: object
*/
/**
* @swagger
* /usage/logs:
* get:
* tags: [Usage]
* summary: 사용 로그 조회
* description: API 호출 로그를 조회합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: 성공
*/
/**
* @swagger
* /admin/users:
* get:
* tags: [Admin]
* summary: 사용자 목록 조회 (관리자)
* description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/providers:
* get:
* tags: [Admin]
* summary: LLM 프로바이더 목록 (관리자)
* description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/stats:
* get:
* tags: [Admin]
* summary: 시스템 통계 (관리자)
* description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
+5
View File
@@ -24,6 +24,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -34,6 +35,10 @@ dependencies {
implementation 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springframework.boot:spring-boot-starter-quartz'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
@@ -1,5 +1,6 @@
package com.erp;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@@ -8,6 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.erp.ai.mapper")
public class ErpApplication {
public static void main(String[] args) {
SpringApplication.run(ErpApplication.class, args);
@@ -0,0 +1,133 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Anthropic Claude LLM 클라이언트.
* /v1/messages 엔드포인트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AnthropicLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
private static final String ANTHROPIC_VERSION = "2023-06-01";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && model.startsWith("claude-");
}
@Override
public String providerName() {
return "anthropic";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("anthropic");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("Anthropic 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
// OpenAI -> Anthropic 메시지 매핑
List<Map<String, Object>> openaiMsgs = (List<Map<String, Object>>) request.getOrDefault("messages", List.of());
String systemPrompt = openaiMsgs.stream()
.filter(m -> "system".equals(m.get("role")))
.map(m -> String.valueOf(m.get("content")))
.findFirst().orElse(null);
List<Map<String, Object>> anthropicMsgs = openaiMsgs.stream()
.filter(m -> !"system".equals(m.get("role")))
.map(m -> Map.of("role", String.valueOf(m.get("role")), "content", m.get("content")))
.toList();
Map<String, Object> body = new HashMap<>();
body.put("model", request.get("model"));
body.put("messages", anthropicMsgs);
if (systemPrompt != null) body.put("system", systemPrompt);
body.put("max_tokens", request.getOrDefault("max_tokens", 2000));
if (request.get("temperature") != null) body.put("temperature", request.get("temperature"));
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/v1/messages")
.contentType(MediaType.APPLICATION_JSON)
.header("x-api-key", apiKey)
.header("anthropic-version", ANTHROPIC_VERSION)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, String.valueOf(request.get("model")));
} catch (RestClientResponseException e) {
log.error("Anthropic API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Anthropic API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Anthropic 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> anthropic, String model) {
if (anthropic == null) return Map.of();
// Anthropic content: [{type:"text", text:"..."}]
String text = "";
Object content = anthropic.get("content");
if (content instanceof List<?> list && !list.isEmpty()) {
Object first = list.get(0);
if (first instanceof Map<?, ?> mapItem) {
Object t = mapItem.get("text");
if (t != null) text = String.valueOf(t);
}
}
Map<String, Object> usageOut = new HashMap<>();
Map<String, Object> usage = (Map<String, Object>) anthropic.getOrDefault("usage", Map.of());
Number prompt = (Number) usage.getOrDefault("input_tokens", 0);
Number completion = (Number) usage.getOrDefault("output_tokens", 0);
usageOut.put("prompt_tokens", prompt.intValue());
usageOut.put("completion_tokens", completion.intValue());
usageOut.put("total_tokens", prompt.intValue() + completion.intValue());
List<Map<String, Object>> choices = new ArrayList<>();
choices.add(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", text),
"finish_reason", anthropic.getOrDefault("stop_reason", "stop")
));
Map<String, Object> out = new HashMap<>();
out.put("id", anthropic.getOrDefault("id", "chatcmpl-" + UUID.randomUUID()));
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", choices);
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,140 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Google Gemini LLM 클라이언트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GeminiLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && model.startsWith("gemini-");
}
@Override
public String providerName() {
return "google";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("google");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("Google 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
String model = String.valueOf(request.get("model"));
// OpenAI messages -> Gemini contents
List<Map<String, Object>> openaiMsgs = (List<Map<String, Object>>) request.getOrDefault("messages", List.of());
List<Map<String, Object>> geminiContents = new ArrayList<>();
StringBuilder systemBuf = new StringBuilder();
for (Map<String, Object> m : openaiMsgs) {
String role = String.valueOf(m.get("role"));
String content = String.valueOf(m.get("content"));
if ("system".equals(role)) {
systemBuf.append(content).append("\n");
} else {
String mapped = "assistant".equals(role) ? "model" : "user";
geminiContents.add(Map.of(
"role", mapped,
"parts", List.of(Map.of("text", content))
));
}
}
Map<String, Object> body = new HashMap<>();
body.put("contents", geminiContents);
if (systemBuf.length() > 0) {
body.put("systemInstruction", Map.of("parts", List.of(Map.of("text", systemBuf.toString()))));
}
Map<String, Object> genConfig = new HashMap<>();
if (request.get("max_tokens") != null) genConfig.put("maxOutputTokens", request.get("max_tokens"));
if (request.get("temperature") != null) genConfig.put("temperature", request.get("temperature"));
if (!genConfig.isEmpty()) body.put("generationConfig", genConfig);
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/v1beta/models/" + model + ":generateContent?key=" + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, model);
} catch (RestClientResponseException e) {
log.error("Gemini API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Gemini API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Gemini 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> gemini, String model) {
if (gemini == null) return Map.of();
String text = "";
List<Map<String, Object>> candidates = (List<Map<String, Object>>) gemini.getOrDefault("candidates", List.of());
if (!candidates.isEmpty()) {
Map<String, Object> first = candidates.get(0);
Map<String, Object> content = (Map<String, Object>) first.get("content");
if (content != null) {
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.getOrDefault("parts", List.of());
if (!parts.isEmpty()) {
Object t = parts.get(0).get("text");
if (t != null) text = String.valueOf(t);
}
}
}
Map<String, Object> usageMeta = (Map<String, Object>) gemini.getOrDefault("usageMetadata", Map.of());
Number prompt = (Number) usageMeta.getOrDefault("promptTokenCount", 0);
Number completion = (Number) usageMeta.getOrDefault("candidatesTokenCount", 0);
Number total = (Number) usageMeta.getOrDefault("totalTokenCount", prompt.intValue() + completion.intValue());
Map<String, Object> usageOut = new HashMap<>();
usageOut.put("prompt_tokens", prompt.intValue());
usageOut.put("completion_tokens", completion.intValue());
usageOut.put("total_tokens", total.intValue());
Map<String, Object> out = new HashMap<>();
out.put("id", "chatcmpl-" + UUID.randomUUID());
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", List.of(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", text),
"finish_reason", "stop")));
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,36 @@
package com.erp.ai.client;
import java.util.List;
import java.util.Map;
/**
* LLM 클라이언트 추상화.
* architecture §9.2.
*/
public interface LlmClient {
/**
* 모델 매칭 (LlmClientFactory 라우팅용).
*/
boolean supports(String model);
/**
* provider name (anthropic / openai / google / deepseek / ollama).
*/
String providerName();
/**
* 동기 채팅 호출. OpenAI 호환 형식 응답.
*
* @param request {model, messages, max_tokens, temperature, ...}
* @return OpenAI 호환 응답 맵 (id, object, choices, usage 등)
*/
Map<String, Object> chat(Map<String, Object> request);
/**
* 사용 가능한 모델 목록.
*/
default List<Map<String, Object>> listModels() {
return List.of();
}
}
@@ -0,0 +1,32 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 모델 이름으로 적절한 LlmClient 구현체 라우팅.
* architecture §9.3.
*/
@Component
@RequiredArgsConstructor
public class LlmClientFactory {
private final List<LlmClient> clients;
public LlmClient pick(String model) {
if (model == null || model.isBlank()) {
throw new LlmClientException("모델이 지정되지 않았습니다.");
}
return clients.stream()
.filter(c -> c.supports(model))
.findFirst()
.orElseThrow(() -> new LlmClientException("지원하지 않는 모델: " + model));
}
public List<LlmClient> all() {
return clients;
}
}
@@ -0,0 +1,117 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Ollama 로컬 LLM 클라이언트.
* /api/chat 엔드포인트.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OllamaLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "http://localhost:11434";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
@Override
public boolean supports(String model) {
// Ollama는 사용자 정의 모델명. 하위 호환을 위해 다른 클라이언트가 지원 안하는 경우 fallback.
// 명시적 prefix: "ollama:" 또는 "llama-" / "mistral-" 등
if (model == null) return false;
String lower = model.toLowerCase();
return lower.startsWith("ollama:")
|| lower.startsWith("llama")
|| lower.startsWith("mistral")
|| lower.startsWith("qwen")
|| lower.startsWith("phi");
}
@Override
public String providerName() {
return "ollama";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("ollama");
String baseUrl = provider != null && provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
String model = String.valueOf(request.get("model"));
if (model.startsWith("ollama:")) model = model.substring(7);
Map<String, Object> body = new HashMap<>();
body.put("model", model);
body.put("messages", request.getOrDefault("messages", List.of()));
body.put("stream", false);
Map<String, Object> options = new HashMap<>();
if (request.get("max_tokens") != null) options.put("num_predict", request.get("max_tokens"));
if (request.get("temperature") != null) options.put("temperature", request.get("temperature"));
if (!options.isEmpty()) body.put("options", options);
try {
Map<String, Object> resp = httpClient.post()
.uri(baseUrl + "/api/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
return convertToOpenAiFormat(resp, model);
} catch (RestClientResponseException e) {
log.error("Ollama API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("Ollama API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("Ollama 호출 실패: " + e.getMessage(), e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> convertToOpenAiFormat(Map<String, Object> ollama, String model) {
if (ollama == null) return Map.of();
Map<String, Object> message = (Map<String, Object>) ollama.getOrDefault("message", Map.of());
String content = String.valueOf(message.getOrDefault("content", ""));
Number promptCount = (Number) ollama.getOrDefault("prompt_eval_count", 0);
Number evalCount = (Number) ollama.getOrDefault("eval_count", 0);
Map<String, Object> usageOut = new HashMap<>();
usageOut.put("prompt_tokens", promptCount.intValue());
usageOut.put("completion_tokens", evalCount.intValue());
usageOut.put("total_tokens", promptCount.intValue() + evalCount.intValue());
List<Map<String, Object>> choices = new ArrayList<>();
choices.add(Map.of(
"index", 0,
"message", Map.of("role", "assistant", "content", content),
"finish_reason", "stop"
));
Map<String, Object> out = new HashMap<>();
out.put("id", "chatcmpl-" + UUID.randomUUID());
out.put("object", "chat.completion");
out.put("created", System.currentTimeMillis() / 1000);
out.put("model", model);
out.put("choices", choices);
out.put("usage", usageOut);
return out;
}
}
@@ -0,0 +1,67 @@
package com.erp.ai.client;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.Map;
/**
* OpenAI LLM 클라이언트 (DeepSeek 등 OpenAI 호환 프로바이더 포함).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiLlmClient implements LlmClient {
private static final String DEFAULT_BASE_URL = "https://api.openai.com";
@Qualifier("llmRestClient")
private final RestClient httpClient;
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Override
public boolean supports(String model) {
return model != null && (model.startsWith("gpt-") || model.startsWith("o1-") || model.startsWith("o3-"));
}
@Override
public String providerName() {
return "openai";
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> chat(Map<String, Object> request) {
AiLlmProvider provider = providerMapper.getByName("openai");
if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) {
throw new LlmClientException("OpenAI 프로바이더가 활성화되지 않았습니다.");
}
String apiKey = cipher.decrypt(provider.getApi_key_encrypted());
String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL;
try {
return httpClient.post()
.uri(baseUrl + "/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + apiKey)
.body(request)
.retrieve()
.body(Map.class);
} catch (RestClientResponseException e) {
log.error("OpenAI API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString());
throw new LlmClientException("OpenAI API 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (Exception e) {
throw new LlmClientException("OpenAI 호출 실패: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,160 @@
package com.erp.ai.client;
import com.erp.ai.config.OpenClawProperties;
import com.erp.ai.exception.OpenClawException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.util.List;
import java.util.Map;
/**
* OpenClaw 외부 AI 엔진(port 18789) HTTP 클라이언트.
* Spring RestClient(동기) 사용 — invyone 기존 RestTemplate 패턴과 일관성 유지.
* enabled=false 이면 모든 메서드가 OpenClawException(503) 발생.
*/
@Slf4j
@Component
public class OpenClawClient {
private final OpenClawProperties props;
private final RestClient restClient;
public OpenClawClient(OpenClawProperties props) {
this.props = props;
this.restClient = RestClient.builder()
.baseUrl(props.getGatewayUrl())
.defaultHeader("X-Pipeline-Source", "invyone")
.build();
}
/**
* OpenClaw 헬스 상태 확인.
*
* @return true = 정상, false = 응답 없음 또는 비정상
*/
public boolean isHealthy() {
if (!props.isEnabled()) {
return false;
}
try {
restClient.get()
.uri("/health")
.retrieve()
.toBodilessEntity();
return true;
} catch (Exception e) {
log.warn("OpenClaw health check failed: {}", e.getMessage());
return false;
}
}
/**
* OpenAI 호환 chat/completions 프록시.
*
* @param request OpenAI 형식 요청 맵 (model, messages, ...)
* @return OpenAI 형식 응답 맵
* @throws OpenClawException 비활성 또는 HTTP 오류 시
*/
@SuppressWarnings("unchecked")
public Map<String, Object> chatCompletion(Map<String, Object> request) {
assertEnabled();
try {
return restClient.post()
.uri("/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Map.class);
} catch (RestClientResponseException e) {
log.error("OpenClaw chatCompletion HTTP error {}: {}", e.getStatusCode().value(), e.getMessage());
throw new OpenClawException("OpenClaw 응답 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
log.warn("OpenClaw chatCompletion connection failed: {}", e.getMessage());
throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw에서 사용 가능한 모델 목록 조회.
*
* @return 모델 ID 목록
* @throws OpenClawException 비활성 또는 HTTP 오류 시
*/
@SuppressWarnings("unchecked")
public List<String> listModels() {
assertEnabled();
try {
Map<String, Object> response = restClient.get()
.uri("/v1/models")
.retrieve()
.body(Map.class);
if (response == null) {
return List.of();
}
Object data = response.get("data");
if (data instanceof List<?> list) {
return list.stream()
.filter(item -> item instanceof Map)
.map(item -> (String) ((Map<?, ?>) item).get("id"))
.filter(id -> id != null)
.toList();
}
return List.of();
} catch (RestClientResponseException e) {
log.error("OpenClaw listModels HTTP error {}: {}", e.getStatusCode().value(), e.getMessage());
throw new OpenClawException("OpenClaw 모델 목록 조회 오류: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
log.warn("OpenClaw listModels connection failed: {}", e.getMessage());
throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw auth profiles 동기화 (LLM API 키들).
*/
public void syncAuthProfiles(Map<String, Object> body) {
assertEnabled();
try {
restClient.post()
.uri("/v1/sync/auth-profiles")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (RestClientResponseException e) {
throw new OpenClawException("OpenClaw auth-profiles sync HTTP error: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
throw new OpenClawException("OpenClaw auth-profiles sync 연결 실패: " + e.getMessage(), e);
}
}
/**
* OpenClaw agents 동기화.
*/
public void syncAgents(Map<String, Object> body) {
assertEnabled();
try {
restClient.post()
.uri("/v1/sync/agents")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (RestClientResponseException e) {
throw new OpenClawException("OpenClaw agents sync HTTP error: " + e.getMessage(), e.getStatusCode().value());
} catch (ResourceAccessException e) {
throw new OpenClawException("OpenClaw agents sync 연결 실패: " + e.getMessage(), e);
}
}
private void assertEnabled() {
if (!props.isEnabled()) {
throw new OpenClawException("OpenClaw 비활성 상태입니다. OPENCLAW_ENABLED=true 로 설정하세요.", 503);
}
}
}
@@ -0,0 +1,46 @@
package com.erp.ai.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestClient;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* AI 에이전트 인프라 빈 설정.
* - aiAgentExecutor: 가상 스레드 ExecutorService (병렬 LLM 호출용)
* - aiObjectMapper: JSON 파싱용 (Spring 기본 ObjectMapper 재사용)
* - llmRestClient: LLM HTTP 호출용 RestClient
*/
@Configuration
public class AiAgentConfig {
/**
* 가상 스레드 풀 — IO-bound LLM 호출 다수 동시 실행.
*/
@Bean(name = "aiAgentExecutor")
public ExecutorService aiAgentExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
* AI 모듈 전용 ObjectMapper. 기존 Spring 의 ObjectMapper 와 별개로 명시적으로 선언.
*/
@Bean(name = "aiObjectMapper")
public ObjectMapper aiObjectMapper() {
return new ObjectMapper();
}
/**
* LLM HTTP 클라이언트. baseUrl 미지정 — 각 LlmClient 구현체에서 절대 URL 사용.
*/
@Bean(name = "llmRestClient")
public RestClient llmRestClient() {
return RestClient.builder()
.defaultHeader("User-Agent", "invyone-ai/1.0")
.build();
}
}
@@ -0,0 +1,17 @@
package com.erp.ai.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Data
@Configuration
@ConfigurationProperties(prefix = "openclaw")
public class OpenClawProperties {
private boolean enabled = false;
private String gatewayUrl = "http://localhost:18789";
private Duration timeout = Duration.ofSeconds(60);
private Duration healthCheckInterval = Duration.ofSeconds(30);
}
@@ -0,0 +1,63 @@
package com.erp.ai.controller;
import com.erp.ai.dto.ApiKeyCreateRequest;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/keys")
@RequiredArgsConstructor
@Slf4j
public class AiAgentApiKeyController {
private final AiAgentApiKeyService apiKeyService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "role", required = false) String role) {
boolean isAdmin = "SUPER_ADMIN".equals(role) || "COMPANY_ADMIN".equals(role);
return ResponseEntity.ok(ApiResponse.success(
apiKeyService.list(userId != null ? userId : "system", isAdmin)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestBody ApiKeyCreateRequest req) {
AiAgentApiKeyService.CreatedKey result = apiKeyService.create(req,
userId != null ? userId : "system", companyCode);
Map<String, Object> data = new HashMap<>();
data.put("id", result.key().getId());
data.put("name", result.key().getName());
data.put("key_prefix", result.key().getKey_prefix());
data.put("user_id", result.key().getUser_id());
data.put("agent_id", result.key().getAgent_id());
data.put("permissions", result.key().getPermissions());
data.put("rate_limit", result.key().getRate_limit());
data.put("monthly_token_limit", result.key().getMonthly_token_limit());
data.put("status", result.key().getStatus());
data.put("expires_at", result.key().getExpires_at());
data.put("created_at", result.key().getCreated_at());
data.put("plain_key", result.plainKey());
return ResponseEntity.status(201).body(
ApiResponse.success(data, "API 키가 생성되었습니다. 키는 한 번만 표시됩니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> revoke(
@PathVariable long id,
@RequestAttribute(value = "user_id", required = false) String userId) {
apiKeyService.revoke(id, userId != null ? userId : "system");
return ResponseEntity.ok(ApiResponse.success(null, "API 키가 폐기되었습니다."));
}
}
@@ -0,0 +1,71 @@
package com.erp.ai.controller;
import com.erp.ai.dto.AgentRequest;
import com.erp.ai.model.AiAgent;
import com.erp.ai.service.AiAgentService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents")
@RequiredArgsConstructor
@Slf4j
public class AiAgentController {
private final AiAgentService aiAgentService;
@GetMapping
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String status,
@RequestParam(value = "company_code", required = false) String companyCodeParam,
@RequestParam(required = false) String search) {
boolean isSuper = "SUPER_ADMIN".equals(role);
String filter = ("*".equals(companyCodeParam) || isSuper)
? null
: (companyCodeParam != null ? companyCodeParam : companyCode);
return ResponseEntity.ok(ApiResponse.success(aiAgentService.list(status, filter, search)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> agent = aiAgentService.getById(id);
if (agent == null) {
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(agent));
}
@PostMapping
public ResponseEntity<ApiResponse<AiAgent>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody AgentRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(aiAgentService.create(req, userId != null ? userId : "system"),
"에이전트가 생성되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiAgent>> update(
@PathVariable long id,
@RequestBody AgentRequest req) {
AiAgent updated = aiAgentService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "에이전트가 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
aiAgentService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "에이전트가 삭제되었습니다."));
}
}
@@ -0,0 +1,46 @@
package com.erp.ai.controller;
import com.erp.ai.service.AiAgentConversationService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/conversations")
@RequiredArgsConstructor
public class AiAgentConversationController {
private final AiAgentConversationService conversationService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(value = "agent_id", required = false) Long agentId) {
Map<String, Object> data = conversationService.list(page, limit, agentId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", data.get("conversations"));
response.put("total", data.get("total"));
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> data = conversationService.getById(id);
if (data.get("conversation") == null) {
return ResponseEntity.status(404).body(ApiResponse.error("대화를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(data));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
conversationService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "대화가 삭제되었습니다."));
}
}
@@ -0,0 +1,92 @@
package com.erp.ai.controller;
import com.erp.ai.dto.GroupMemberRequest;
import com.erp.ai.dto.GroupRequest;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentGroupMember;
import com.erp.ai.service.AiAgentGroupService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agent-groups")
@RequiredArgsConstructor
public class AiAgentGroupController {
private final AiAgentGroupService groupService;
@GetMapping
public ResponseEntity<ApiResponse<List<AiAgentGroup>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode) {
return ResponseEntity.ok(ApiResponse.success(groupService.list(companyCode)));
}
@GetMapping("/connectors")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> connectors() {
return ResponseEntity.ok(ApiResponse.success(groupService.getAvailableConnectors()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> data = groupService.getById(id);
if (data == null) {
return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(data));
}
@PostMapping
public ResponseEntity<ApiResponse<AiAgentGroup>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody GroupRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(groupService.create(req, userId != null ? userId : "system"),
"멀티 에이전트 그룹이 생성되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiAgentGroup>> update(
@PathVariable long id,
@RequestBody GroupRequest req) {
AiAgentGroup updated = groupService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "그룹이 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
groupService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "그룹이 삭제되었습니다."));
}
// ===== 멤버 관리 =====
@PostMapping("/{id}/members")
public ResponseEntity<ApiResponse<AiAgentGroupMember>> addMember(
@PathVariable long id,
@RequestBody GroupMemberRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(groupService.addMember(id, req), "멤버가 추가되었습니다."));
}
@PutMapping("/members/{memberId}")
public ResponseEntity<ApiResponse<AiAgentGroupMember>> updateMember(
@PathVariable long memberId,
@RequestBody GroupMemberRequest req) {
return ResponseEntity.ok(
ApiResponse.success(groupService.updateMember(memberId, req), "멤버가 수정되었습니다."));
}
@DeleteMapping("/members/{memberId}")
public ResponseEntity<ApiResponse<Void>> removeMember(@PathVariable long memberId) {
groupService.removeMember(memberId);
return ResponseEntity.ok(ApiResponse.success(null, "멤버가 제거되었습니다."));
}
}
@@ -0,0 +1,50 @@
package com.erp.ai.controller;
import com.erp.ai.dto.ProviderRequest;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.service.AiAgentProviderService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/providers")
@RequiredArgsConstructor
@Slf4j
public class AiAgentProviderController {
private final AiAgentProviderService providerService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list() {
return ResponseEntity.ok(ApiResponse.success(providerService.list()));
}
@PostMapping
public ResponseEntity<ApiResponse<AiLlmProvider>> create(@RequestBody ProviderRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(providerService.create(req), "프로바이더가 추가되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiLlmProvider>> update(
@PathVariable long id,
@RequestBody ProviderRequest req) {
AiLlmProvider updated = providerService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("프로바이더를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "프로바이더가 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
providerService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "프로바이더가 삭제되었습니다."));
}
}
@@ -0,0 +1,43 @@
package com.erp.ai.controller;
import com.erp.ai.dto.UsageSummaryResponse;
import com.erp.ai.service.AiAgentUsageService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents/usage")
@RequiredArgsConstructor
public class AiAgentUsageController {
private final AiAgentUsageService usageService;
@GetMapping("/summary")
public ResponseEntity<ApiResponse<UsageSummaryResponse>> summary() {
return ResponseEntity.ok(ApiResponse.success(usageService.getSummary()));
}
@GetMapping("/logs")
public ResponseEntity<Map<String, Object>> logs(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit) {
Map<String, Object> result = usageService.getLogs(page, limit);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result.get("logs"));
response.put("total", result.get("total"));
return ResponseEntity.ok(response);
}
@GetMapping("/daily")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> daily(
@RequestParam(defaultValue = "30") int days) {
return ResponseEntity.ok(ApiResponse.success(usageService.getDailyUsage(days)));
}
}
@@ -0,0 +1,71 @@
package com.erp.ai.controller;
import com.erp.ai.dto.KnowledgeFileRequest;
import com.erp.ai.model.AiKnowledgeFile;
import com.erp.ai.service.AiKnowledgeService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/ai-knowledge")
@RequiredArgsConstructor
@Slf4j
public class AiKnowledgeController {
private final AiKnowledgeService aiKnowledgeService;
@GetMapping
public ResponseEntity<ApiResponse<List<AiKnowledgeFile>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search) {
boolean isSuper = "SUPER_ADMIN".equals(role);
String filter = isSuper ? null : companyCode;
return ResponseEntity.ok(ApiResponse.success(aiKnowledgeService.list(category, filter)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<AiKnowledgeFile>> getById(@PathVariable long id) {
AiKnowledgeFile file = aiKnowledgeService.getById(id);
if (file == null) {
return ResponseEntity.status(404).body(ApiResponse.error("지식 파일을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(file));
}
@PostMapping
public ResponseEntity<ApiResponse<AiKnowledgeFile>> create(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestBody KnowledgeFileRequest req) {
return ResponseEntity.status(201).body(
ApiResponse.success(
aiKnowledgeService.create(req,
userId != null ? userId : "system",
companyCode != null ? companyCode : "*"),
"지식 파일이 등록되었습니다."));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AiKnowledgeFile>> update(
@PathVariable long id,
@RequestBody KnowledgeFileRequest req) {
AiKnowledgeFile updated = aiKnowledgeService.update(id, req);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("지식 파일을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "지식 파일이 수정되었습니다."));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable long id) {
aiKnowledgeService.delete(id);
return ResponseEntity.ok(ApiResponse.success(null, "지식 파일이 삭제되었습니다."));
}
}
@@ -0,0 +1,246 @@
package com.erp.ai.controller;
import com.erp.ai.client.LlmClient;
import com.erp.ai.client.LlmClientFactory;
import com.erp.ai.dto.ChatCompletionRequest;
import com.erp.ai.dto.GroupExecutionResult;
import com.erp.ai.exception.LlmClientException;
import com.erp.ai.model.AiAgentGroup;
import com.erp.ai.model.AiAgentUsageLog;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.ai.service.AiAgentGroupService;
import com.erp.ai.service.AiAgentProviderService;
import com.erp.ai.service.AiAgentUsageService;
import com.erp.ai.service.MultiAgentExecutionEngine;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 외부 게이트웨이 (OpenAI 호환).
* vexplor openClawProxyRoutes.ts 1:1 포팅.
*
* 인증: Epic D 의 ApiKeyAuthenticationFilter 가 sk-pipe-* 검증 후
* request attribute "ai_api_key_id" / "ai_api_key_user" 설정.
* 필터 미설치 시: 헤더 fallback 처리 (개발 단계).
*/
@Slf4j
@RestController
@RequestMapping("/api/ai/v1")
@RequiredArgsConstructor
public class AiProxyController {
private final LlmClientFactory llmClientFactory;
private final MultiAgentExecutionEngine executionEngine;
private final AiAgentGroupService groupService;
private final AiAgentApiKeyService apiKeyService;
private final AiAgentUsageService usageService;
private final AiAgentProviderService providerService;
/**
* POST /api/ai/v1/chat/completions — OpenAI 호환 채팅.
*/
@PostMapping("/chat/completions")
public ResponseEntity<?> chatCompletions(@RequestBody ChatCompletionRequest req,
HttpServletRequest httpRequest) {
long startTime = System.currentTimeMillis();
ApiKeyContext ctx = resolveApiKey(httpRequest);
try {
Map<String, Object> body = new HashMap<>();
body.put("model", req.getModel());
body.put("messages", req.getMessages());
if (req.getMax_tokens() != null) body.put("max_tokens", req.getMax_tokens());
if (req.getTemperature() != null) body.put("temperature", req.getTemperature());
LlmClient client = llmClientFactory.pick(req.getModel());
Map<String, Object> result = client.chat(body);
// 사용량 추적
Map<String, Object> usage = (Map<String, Object>) result.get("usage");
int totalTokens = 0;
int promptTokens = 0;
int completionTokens = 0;
if (usage != null) {
if (usage.get("total_tokens") instanceof Number n) totalTokens = n.intValue();
if (usage.get("prompt_tokens") instanceof Number n) promptTokens = n.intValue();
if (usage.get("completion_tokens") instanceof Number n) completionTokens = n.intValue();
}
long elapsed = System.currentTimeMillis() - startTime;
AiAgentUsageLog log = new AiAgentUsageLog();
log.setUser_id(ctx.userId);
log.setApi_key_id(ctx.keyId);
log.setProvider_name(client.providerName());
log.setModel_name(req.getModel());
log.setPrompt_tokens(promptTokens);
log.setCompletion_tokens(completionTokens);
log.setTotal_tokens(totalTokens);
log.setResponse_time_ms((int) elapsed);
log.setSuccess(true);
log.setRequest_path("/v1/chat/completions");
log.setIp_address(httpRequest.getRemoteAddr());
usageService.log(log);
if (ctx.keyId != null && totalTokens > 0) {
apiKeyService.addTokenUsage(ctx.keyId, totalTokens);
}
return ResponseEntity.ok(result);
} catch (LlmClientException e) {
long elapsed = System.currentTimeMillis() - startTime;
AiAgentUsageLog log = new AiAgentUsageLog();
log.setUser_id(ctx.userId);
log.setApi_key_id(ctx.keyId);
log.setModel_name(req.getModel());
log.setResponse_time_ms((int) elapsed);
log.setSuccess(false);
log.setError_message(e.getMessage());
log.setRequest_path("/v1/chat/completions");
log.setIp_address(httpRequest.getRemoteAddr());
usageService.log(log);
return ResponseEntity.status(e.getStatusCode())
.body(Map.of("error", Map.of("message", e.getMessage(), "type", "server_error")));
}
}
/**
* POST /api/ai/v1/groups/{groupId} — 멀티 에이전트 그룹 실행.
*/
@PostMapping("/groups/{groupId}")
public ResponseEntity<?> executeGroup(@PathVariable String groupId,
@RequestBody Map<String, Object> body,
HttpServletRequest httpRequest) {
ApiKeyContext ctx = resolveApiKey(httpRequest);
Object messageObj = body.get("message");
if (messageObj == null) {
return ResponseEntity.badRequest().body(
Map.of("error", Map.of("message", "message is required", "type", "invalid_request")));
}
String message = String.valueOf(messageObj);
long actualGroupId;
try {
actualGroupId = Long.parseLong(groupId);
} catch (NumberFormatException e) {
AiAgentGroup g = groupService.getByGroupId(groupId);
if (g == null) {
return ResponseEntity.status(404).body(
Map.of("error", Map.of("message", "Group not found", "type", "not_found")));
}
actualGroupId = g.getId();
}
try {
GroupExecutionResult result = executionEngine.execute(actualGroupId, message, ctx.userId, ctx.keyId);
Map<String, Object> data = new HashMap<>();
data.put("group", result.getGroupName());
data.put("execution_mode", result.getExecutionMode());
data.put("total_tokens", result.getTotalTokens());
data.put("duration_ms", result.getTotalDurationMs());
data.put("steps", result.getSteps());
data.put("summary", result.getFinalSummary());
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", data);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(
Map.of("error", Map.of("message", e.getMessage(), "type", "execution_error")));
}
}
/**
* GET /api/ai/v1/groups — 사용 가능한 멀티 에이전트 그룹.
*/
@GetMapping("/groups")
public ResponseEntity<Map<String, Object>> listGroups() {
List<AiAgentGroup> groups = groupService.list(null);
Map<String, Object> resp = new HashMap<>();
resp.put("success", true);
resp.put("data", groups);
return ResponseEntity.ok(resp);
}
/**
* GET /api/ai/v1/models — 사용 가능한 모델 목록.
*/
@GetMapping("/models")
public ResponseEntity<Map<String, Object>> listModels() {
List<Map<String, Object>> models = new ArrayList<>();
try {
providerService.getActiveProviders().forEach(p ->
models.add(Map.of(
"id", p.getModel_name(),
"object", "model",
"owned_by", p.getName())));
} catch (Exception ignored) {
}
if (models.isEmpty()) {
// fallback
models.add(Map.of("id", "claude-sonnet-4-20250514", "object", "model", "owned_by", "anthropic"));
models.add(Map.of("id", "gpt-4o", "object", "model", "owned_by", "openai"));
models.add(Map.of("id", "gpt-4o-mini", "object", "model", "owned_by", "openai"));
}
Map<String, Object> resp = new HashMap<>();
resp.put("object", "list");
resp.put("data", models);
return ResponseEntity.ok(resp);
}
/**
* GET /api/ai/v1/health — AI 엔진 상태 확인.
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
int activeProviders;
try {
activeProviders = providerService.getActiveProviders().size();
} catch (Exception e) {
activeProviders = 0;
}
Map<String, Object> resp = new HashMap<>();
resp.put("status", activeProviders > 0 ? "running" : "no_providers");
resp.put("engine", "invyone-native");
resp.put("providers", activeProviders);
return ResponseEntity.ok(resp);
}
/**
* API key context 해석.
* 1. ApiKeyAuthenticationFilter (Epic D) 가 설정한 attribute 우선
* 2. fallback: Authorization 헤더 직접 파싱 + DB 검증
*/
private ApiKeyContext resolveApiKey(HttpServletRequest request) {
Long keyId = (Long) request.getAttribute("ai_api_key_id");
String userId = (String) request.getAttribute("ai_api_key_user");
if (keyId != null) {
return new ApiKeyContext(keyId, userId);
}
// fallback: 직접 검증
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (token.startsWith("sk-pipe-")) {
var key = apiKeyService.validateKey(token);
if (key != null) {
return new ApiKeyContext(key.getId(), key.getUser_id());
}
}
}
// JWT 컨텍스트 fallback
Object jwtUserId = request.getAttribute("user_id");
return new ApiKeyContext(null, jwtUserId != null ? String.valueOf(jwtUserId) : null);
}
private record ApiKeyContext(Long keyId, String userId) {}
}
@@ -0,0 +1,8 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class AgentExecuteRequest {
private String message;
}
@@ -0,0 +1,22 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* AI 에이전트 생성/수정 요청.
*/
@Data
public class AgentRequest {
private String agent_id;
private String name;
private String description;
private String model;
private String system_prompt;
private List<Object> tools;
private Map<String, Object> config;
private String company_code;
private String status;
}
@@ -0,0 +1,16 @@
package com.erp.ai.dto;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.List;
@Data
public class ApiKeyCreateRequest {
private String name;
private Long agent_id;
private List<String> permissions;
private Integer rate_limit;
private Long monthly_token_limit;
private OffsetDateTime expires_at;
}
@@ -0,0 +1,18 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* OpenAI 호환 chat/completions 요청 DTO.
*/
@Data
public class ChatCompletionRequest {
private String model;
private List<Map<String, Object>> messages;
private Boolean stream;
private Integer max_tokens;
private Double temperature;
}
@@ -0,0 +1,25 @@
package com.erp.ai.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.util.List;
import java.util.Map;
/**
* OpenAI 호환 chat/completions 응답 DTO.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatCompletionResponse {
private String id;
private String object;
private Long created;
private String model;
private List<Map<String, Object>> choices;
private Map<String, Object> usage;
}
@@ -0,0 +1,27 @@
package com.erp.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 멀티 에이전트 그룹 실행 결과.
* vexplor multiAgentExecutionEngine.ts:21-29 1:1 포팅.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GroupExecutionResult {
private long groupId;
private String groupName;
private String executionMode;
private List<Map<String, Object>> steps;
private String finalSummary;
private long totalTokens;
private long totalDurationMs;
}
@@ -0,0 +1,15 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class GroupMemberRequest {
private Long agent_id;
private String role_name;
private List<Map<String, Object>> connectors;
private Integer execution_order;
private Map<String, Object> config;
}
@@ -0,0 +1,13 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class GroupRequest {
private String name;
private String description;
/** parallel | sequential | mixed */
private String execution_mode;
private String company_code;
private String status;
}
@@ -0,0 +1,14 @@
package com.erp.ai.dto;
import lombok.Data;
@Data
public class KnowledgeFileRequest {
private String name;
private String file_name;
private String category;
private String description;
private String content;
private Long file_size;
private String mime_type;
}
@@ -0,0 +1,21 @@
package com.erp.ai.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProviderRequest {
private String name;
private String display_name;
/** Plain text — 서비스에서 AES-GCM 암호화 후 저장. update 시 null 이면 기존 키 유지 */
private String api_key;
private String model_name;
private String endpoint;
private Integer priority;
private Integer max_tokens;
private BigDecimal temperature;
private BigDecimal cost_per_1k_input;
private BigDecimal cost_per_1k_output;
private Boolean is_active;
}
@@ -0,0 +1,16 @@
package com.erp.ai.dto;
import lombok.Data;
import java.util.Map;
@Data
public class ScheduleRequest {
private String name;
private Long group_id;
private String cron_expression;
private String timezone;
private String input_message;
private Map<String, Object> notification;
private Boolean is_active;
}
@@ -0,0 +1,23 @@
package com.erp.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Builder;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UsageSummaryResponse {
private long today_tokens;
private long today_requests;
private BigDecimal today_cost;
private long month_tokens;
private long month_requests;
private BigDecimal month_cost;
private int active_agents;
private int active_keys;
}
@@ -0,0 +1,11 @@
package com.erp.ai.exception;
public class AiAgentException extends RuntimeException {
public AiAgentException(String message) {
super(message);
}
public AiAgentException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,24 @@
package com.erp.ai.exception;
public class LlmClientException extends RuntimeException {
private final int statusCode;
public LlmClientException(String message) {
super(message);
this.statusCode = 500;
}
public LlmClientException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public LlmClientException(String message, Throwable cause) {
super(message, cause);
this.statusCode = 500;
}
public int getStatusCode() {
return statusCode;
}
}
@@ -0,0 +1,29 @@
package com.erp.ai.exception;
/**
* OpenClaw 외부 엔진 호출 실패 시 발생하는 예외.
* enabled=false 이거나 HTTP 에러 발생 시 graceful 처리용.
*/
public class OpenClawException extends RuntimeException {
private final int statusCode;
public OpenClawException(String message) {
super(message);
this.statusCode = 503;
}
public OpenClawException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public OpenClawException(String message, Throwable cause) {
super(message, cause);
this.statusCode = 503;
}
public int getStatusCode() {
return statusCode;
}
}
@@ -0,0 +1,50 @@
package com.erp.ai.health;
import com.erp.ai.client.OpenClawClient;
import com.erp.ai.config.OpenClawProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* OpenClaw 헬스 상태를 주기적으로 갱신하는 컴포넌트.
* Spring Boot Actuator 미설치 환경을 고려해 독립 구현.
* Actuator 도입 시 HealthIndicator 구현으로 전환 가능.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenClawHealthIndicator {
private final OpenClawClient openClawClient;
private final OpenClawProperties props;
private final AtomicBoolean healthy = new AtomicBoolean(false);
/**
* 설정된 health-check-interval 마다 헬스 상태 갱신.
* fixedRateString 은 application.yml openclaw.health-check-interval(ms 단위 Duration) 사용.
*/
@Scheduled(fixedRateString = "#{@openClawProperties.healthCheckInterval.toMillis()}")
public void checkHealth() {
if (!props.isEnabled()) {
healthy.set(false);
return;
}
boolean result = openClawClient.isHealthy();
if (healthy.get() != result) {
log.info("OpenClaw health changed: {}", result ? "UP" : "DOWN");
}
healthy.set(result);
}
/**
* 현재 캐시된 헬스 상태 반환.
*/
public boolean isHealthy() {
return healthy.get();
}
}
@@ -0,0 +1,28 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentApiKey;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentApiKeyMapper {
List<AiAgentApiKey> listAll();
List<AiAgentApiKey> listByUser(@Param("userId") String userId);
AiAgentApiKey getById(@Param("id") long id);
AiAgentApiKey getByKeyHash(@Param("keyHash") String keyHash);
int insert(AiAgentApiKey key);
int delete(@Param("id") long id, @Param("userId") String userId);
int updateLastUsed(@Param("id") long id);
int addTokenUsage(@Param("id") long id, @Param("tokens") long tokens);
int countActive();
}
@@ -0,0 +1,28 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentConversation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentConversationMapper {
List<AiAgentConversation> list(@Param("agentId") Long agentId,
@Param("limit") int limit,
@Param("offset") int offset);
int count(@Param("agentId") Long agentId);
AiAgentConversation getById(@Param("id") long id);
int insert(AiAgentConversation conv);
int updateMeta(@Param("id") long id,
@Param("title") String title,
@Param("metadata") String metadata);
int incrementStats(@Param("id") long id, @Param("tokens") int tokens);
int delete(@Param("id") long id);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentGroup;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentGroupMapper {
List<AiAgentGroup> list(@Param("companyCode") String companyCode);
AiAgentGroup getById(@Param("id") long id);
AiAgentGroup getByGroupId(@Param("groupId") String groupId);
int insert(AiAgentGroup group);
int update(AiAgentGroup group);
int softDelete(@Param("id") long id);
int touchUpdatedAt(@Param("id") long id);
}
@@ -0,0 +1,22 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentGroupMember;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentGroupMemberMapper {
List<AiAgentGroupMember> listByGroupId(@Param("groupId") long groupId);
AiAgentGroupMember getById(@Param("id") long id);
int insert(AiAgentGroupMember member);
int update(AiAgentGroupMember member);
int delete(@Param("id") long id);
int countByGroupId(@Param("groupId") long groupId);
}
@@ -0,0 +1,27 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiAgentMapper {
List<AiAgent> list(Map<String, Object> filters);
AiAgent getById(@Param("id") long id);
AiAgent getByAgentId(@Param("agentId") String agentId);
int insert(AiAgent agent);
int update(@Param("id") long id, @Param("fields") Map<String, Object> fields);
int softDelete(@Param("id") long id);
int countActive();
List<AiAgent> getActiveAgents();
}
@@ -0,0 +1,16 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentMessage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentMessageMapper {
List<AiAgentMessage> listByConversationId(@Param("conversationId") long conversationId);
int insert(AiAgentMessage message);
int deleteByConversationId(@Param("conversationId") long conversationId);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiAgentScheduleMapper {
List<AiAgentSchedule> listAll();
List<AiAgentSchedule> listActive();
AiAgentSchedule getById(@Param("id") long id);
int insert(AiAgentSchedule schedule);
int update(AiAgentSchedule schedule);
int delete(@Param("id") long id);
int markRun(@Param("id") long id);
}
@@ -0,0 +1,25 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAgentUsageLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiAgentUsageLogMapper {
int insert(AiAgentUsageLog log);
Map<String, Object> getTodayAggregate();
Map<String, Object> getMonthAggregate();
List<AiAgentUsageLog> list(@Param("limit") int limit, @Param("offset") int offset);
int count();
List<Map<String, Object>> getDailyUsage(@Param("days") int days);
long getMonthTokensByApiKey(@Param("apiKeyId") long apiKeyId);
}
@@ -0,0 +1,19 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiAnalysisLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
@Mapper
public interface AiAnalysisLogMapper {
int insert(AiAnalysisLog log);
List<AiAnalysisLog> getRecentLogs(@Param("groupId") long groupId,
@Param("days") int days,
@Param("limit") int limit);
BigDecimal getAverageAccuracy(@Param("groupId") long groupId);
}
@@ -0,0 +1,21 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiKnowledgeFile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AiKnowledgeFileMapper {
List<AiKnowledgeFile> list(Map<String, Object> filters);
AiKnowledgeFile getById(@Param("id") long id);
int insert(AiKnowledgeFile file);
int update(AiKnowledgeFile file);
int delete(@Param("id") long id);
}
@@ -0,0 +1,24 @@
package com.erp.ai.mapper;
import com.erp.ai.model.AiLlmProvider;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AiLlmProviderMapper {
List<AiLlmProvider> listAll();
List<AiLlmProvider> listActive();
AiLlmProvider getById(@Param("id") long id);
AiLlmProvider getByName(@Param("name") String name);
int insert(AiLlmProvider provider);
int update(AiLlmProvider provider);
int delete(@Param("id") long id);
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 모델 (테이블: ai_agents)
* vexplor types/aiAgent.ts:3-17 1:1 포팅.
*/
@Data
public class AiAgent {
private Long id;
private String agent_id;
private String name;
private String description;
private String model;
private String system_prompt;
/** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */
private String tools;
/** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */
private String config;
private String status;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,33 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI API 키 (테이블: ai_agent_api_keys)
* sk-pipe-* 형식 외부 API 인증.
*/
@Data
public class AiAgentApiKey {
private Long id;
private String name;
/** SHA-256 hex */
private String key_hash;
/** sk-pipe-{8hex} 표시용 */
private String key_prefix;
private String user_id;
private String company_code;
private Long agent_id;
/** JSONB string[] */
private String permissions;
private Integer rate_limit;
private Long monthly_token_limit;
/** active | revoked */
private String status;
private OffsetDateTime last_used_at;
private Long usage_count;
private Long total_tokens;
private OffsetDateTime expires_at;
private OffsetDateTime created_at;
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 대화 (테이블: ai_agent_conversations)
*/
@Data
public class AiAgentConversation {
private Long id;
private String conversation_id;
private Long agent_id;
private String user_id;
private Long api_key_id;
private String title;
private Integer message_count;
private Long total_tokens;
private String status;
private String metadata;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: agent_name */
private String agent_name;
/** JOIN: COALESCE(agent name, metadata->>'group_name') */
private String display_name;
}
@@ -0,0 +1,25 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 그룹 (테이블: ai_agent_groups)
*/
@Data
public class AiAgentGroup {
private Long id;
private String group_id;
private String name;
private String description;
/** parallel | sequential | mixed */
private String execution_mode;
private String status;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN 시 사용 — 멤버 수 */
private Integer member_count;
}
@@ -0,0 +1,28 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 에이전트 그룹 멤버 (테이블: ai_agent_group_members)
* connectors / config 는 JSONB → JSON 문자열로 보관.
*/
@Data
public class AiAgentGroupMember {
private Long id;
private Long group_id;
private Long agent_id;
private String role_name;
/** JSONB ConnectorRef[] */
private String connectors;
private Integer execution_order;
private String config;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: ai_agents.name */
private String agent_name;
/** JOIN: ai_agents.model */
private String agent_model;
}
@@ -0,0 +1,21 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 메시지 (테이블: ai_agent_messages)
*/
@Data
public class AiAgentMessage {
private Long id;
private Long conversation_id;
/** system | user | assistant | tool */
private String role;
private String content;
private String tool_calls;
private Integer token_count;
private String metadata;
private OffsetDateTime created_at;
}
@@ -0,0 +1,30 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 스케줄 (테이블: ai_agent_schedules)
* Quartz JobStore + Cron.
*/
@Data
public class AiAgentSchedule {
private Long id;
private String name;
private Long group_id;
private String cron_expression;
private String timezone;
private String input_message;
private String notification;
private Boolean is_active;
private OffsetDateTime last_run_at;
private Long run_count;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
/** JOIN: ai_agent_groups.name */
private String group_name;
}
@@ -0,0 +1,31 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* AI 사용량 로그 (테이블: ai_agent_usage_logs)
*/
@Data
public class AiAgentUsageLog {
private Long id;
private String user_id;
private Long api_key_id;
private Long agent_id;
private Long conversation_id;
private String provider_name;
private String model_name;
private Integer prompt_tokens;
private Integer completion_tokens;
private Integer total_tokens;
private BigDecimal cost_usd;
private Integer response_time_ms;
private Boolean success;
private String error_message;
private String request_path;
private String ip_address;
private String company_code;
private OffsetDateTime created_at;
}
@@ -0,0 +1,29 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* AI 분석 이력 (테이블: ai_analysis_logs)
*/
@Data
public class AiAnalysisLog {
private Long id;
private Long group_id;
private Long agent_id;
private Long schedule_id;
/** manual | api | schedule */
private String execution_type;
private String input_message;
private String analysis_result;
private String prediction;
private String actual_result;
private BigDecimal accuracy_score;
private Integer tokens_used;
private Integer duration_ms;
private String metadata;
private String company_code;
private OffsetDateTime created_at;
}
@@ -0,0 +1,24 @@
package com.erp.ai.model;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* AI 지식 파일 (테이블: ai_knowledge_files)
*/
@Data
public class AiKnowledgeFile {
private Long id;
private String name;
private String file_name;
private String category;
private String description;
private String content;
private Long file_size;
private String mime_type;
private String company_code;
private String created_by;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,30 @@
package com.erp.ai.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* LLM 프로바이더 (테이블: ai_llm_providers)
* api_key_encrypted 는 AES-GCM 암호화 base64 문자열.
*/
@Data
public class AiLlmProvider {
private Long id;
/** anthropic | openai | google | deepseek | ollama */
private String name;
private String display_name;
private String api_key_encrypted;
private String model_name;
private String endpoint;
private Integer priority;
private Integer max_tokens;
private BigDecimal temperature;
private BigDecimal cost_per_1k_input;
private BigDecimal cost_per_1k_output;
private Boolean is_active;
private String config;
private OffsetDateTime created_at;
private OffsetDateTime updated_at;
}
@@ -0,0 +1,87 @@
package com.erp.ai.scheduler;
import com.erp.ai.dto.GroupExecutionResult;
import com.erp.ai.mapper.AiAgentScheduleMapper;
import com.erp.ai.model.AiAgentSchedule;
import com.erp.ai.service.MultiAgentExecutionEngine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* Quartz Job — AI 스케줄 실행.
* architecture §8.2.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MultiAgentExecutionJob implements Job {
@Autowired
private MultiAgentExecutionEngine executionEngine;
@Autowired
private AiAgentScheduleMapper scheduleMapper;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
long scheduleId = context.getMergedJobDataMap().getLong("scheduleId");
AiAgentSchedule schedule = scheduleMapper.getById(scheduleId);
if (schedule == null) {
log.warn("스케줄 없음: {}", scheduleId);
return;
}
log.info("AI 스케줄 실행: {} (ID: {})", schedule.getName(), schedule.getId());
try {
GroupExecutionResult result = executionEngine.execute(
schedule.getGroup_id(),
schedule.getInput_message(),
schedule.getCreated_by(),
null);
scheduleMapper.markRun(schedule.getId());
sendNotification(schedule, result);
log.info("AI 스케줄 완료: {} - {} tokens", schedule.getName(), result.getTotalTokens());
} catch (Exception e) {
log.error("AI 스케줄 실패: {} - {}", schedule.getName(), e.getMessage());
}
}
@SuppressWarnings("unchecked")
private void sendNotification(AiAgentSchedule schedule, GroupExecutionResult result) {
if (schedule.getNotification() == null) return;
try {
// notification 은 JSON 문자열 — 단순 처리: webhook 만 RestClient.post
// 시스템 공지/이메일은 invyone 서비스 호출 (Phase 2).
String n = schedule.getNotification();
if (n.contains("\"webhook\"")) {
int idx = n.indexOf("\"webhook\"");
int colon = n.indexOf(':', idx);
int quoteOpen = n.indexOf('"', colon + 1);
int quoteClose = n.indexOf('"', quoteOpen + 1);
if (quoteOpen > 0 && quoteClose > quoteOpen) {
String webhook = n.substring(quoteOpen + 1, quoteClose);
if (webhook.startsWith("http")) {
RestClient.create().post().uri(webhook)
.body(Map.of("text",
"[" + schedule.getName() + "] 실행 완료\n\n"
+ (result.getFinalSummary().length() > 1000
? result.getFinalSummary().substring(0, 1000)
: result.getFinalSummary())))
.retrieve()
.toBodilessEntity();
}
}
}
} catch (Exception e) {
log.warn("알림 발송 실패: {}", e.getMessage());
}
}
}
@@ -0,0 +1,146 @@
package com.erp.ai.security;
import com.erp.ai.model.AiAgentApiKey;
import com.erp.ai.service.AiAgentApiKeyService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* /api/ai/v1/** 엔드포인트에 대한 API 키 인증 필터.
* Authorization: Bearer sk-pipe-{hex} 형식의 키를 검증합니다.
* SubdomainResolverFilter 뒤, JwtAuthenticationFilter 앞에 위치합니다.
*/
@Slf4j
@RequiredArgsConstructor
public class AiApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_PREFIX = "sk-pipe-";
private static final String AI_API_PATH_PATTERN = "/api/ai/v1/**";
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AiAgentApiKeyService aiAgentApiKeyService;
/**
* /api/ai/v1/** 외 경로는 이 필터를 건너뜁니다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !PATH_MATCHER.match(AI_API_PATH_PATTERN, path);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
// Authorization 헤더가 없거나 sk-pipe- 로 시작하지 않으면 JWT 필터로 패스
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer " + API_KEY_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// "Bearer " 제거 후 실제 키 값 추출
String rawKey = authHeader.substring(7); // "Bearer ".length() == 7
// 키 검증 + last_used_at 갱신 (Epic C 서비스가 SHA-256 + 만료체크 + last_used 갱신을 수행)
AiAgentApiKey apiKey;
try {
apiKey = aiAgentApiKeyService.validateKey(rawKey);
} catch (Exception e) {
log.error("[AiApiKeyAuthFilter] Key validation failure", e);
sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR", "Internal server error");
return;
}
if (apiKey == null) {
log.warn("[AiApiKeyAuthFilter] Invalid or expired API key. prefix={}",
rawKey.length() > 16 ? rawKey.substring(0, 16) + "..." : rawKey);
sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
"INVALID_API_KEY", "Invalid or expired API key");
return;
}
// 만료일 이중 검증 (서비스에서 처리하지 않을 경우 대비)
if (apiKey.getExpires_at() != null && apiKey.getExpires_at().isBefore(OffsetDateTime.now())) {
log.warn("[AiApiKeyAuthFilter] API key expired. keyId={}", apiKey.getId());
sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
"API_KEY_EXPIRED", "API key has expired");
return;
}
// 월간 토큰 한도 체크
if (apiKey.getMonthly_token_limit() != null
&& apiKey.getMonthly_token_limit() > 0
&& apiKey.getTotal_tokens() != null
&& apiKey.getTotal_tokens() >= apiKey.getMonthly_token_limit()) {
log.warn("[AiApiKeyAuthFilter] Monthly token limit exceeded. keyId={}", apiKey.getId());
sendError(response, 429,
"TOKEN_LIMIT_EXCEEDED", "Monthly token limit exceeded");
return;
}
// Request attribute 설정 (JwtAuthenticationFilter 와 동일한 키 사용)
request.setAttribute("user_id", apiKey.getUser_id());
request.setAttribute("company_code", apiKey.getCompany_code());
request.setAttribute("role", "API_KEY_USER");
request.setAttribute("user_type", "API_KEY_USER");
request.setAttribute("api_key_id", apiKey.getId());
// AiProxyController 가 사용하는 attribute 이름과 일치시킴
request.setAttribute("ai_api_key_id", apiKey.getId());
request.setAttribute("ai_api_key_user", apiKey.getUser_id());
// SecurityContext 설정
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_API_KEY_USER")
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(apiKey.getUser_id(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("[AiApiKeyAuthFilter] Authenticated via API key. keyId={} userId={} companyCode={}",
apiKey.getId(), apiKey.getUser_id(), apiKey.getCompany_code());
filterChain.doFilter(request, response);
}
/**
* 401/429 JSON 오류 응답 전송.
*/
private void sendError(HttpServletResponse response, int status, String code, String message)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Map<String, Object> body = Map.of(
"error", Map.of(
"code", code,
"message", message
)
);
response.getWriter().write(OBJECT_MAPPER.writeValueAsString(body));
}
}
@@ -0,0 +1,167 @@
package com.erp.ai.service;
import com.erp.ai.dto.ApiKeyCreateRequest;
import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiAgentApiKeyMapper;
import com.erp.ai.model.AiAgentApiKey;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
/**
* AI API 키 관리 서비스.
* vexplor aiAgentApiKeyService.ts 1:1 포팅.
* - sk-pipe-{64hex} 발급
* - SHA-256 hash 저장 (plain key는 1회 반환 후 폐기)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentApiKeyService {
private final AiAgentApiKeyMapper apiKeyMapper;
private final SecureRandom secureRandom = new SecureRandom();
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<Map<String, Object>> list(String userId, boolean isAdmin) {
List<AiAgentApiKey> keys = isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
return keys.stream().map(this::toResponseMap).toList();
}
private Map<String, Object> toResponseMap(AiAgentApiKey k) {
Map<String, Object> row = new HashMap<>();
row.put("id", k.getId());
row.put("name", k.getName());
row.put("key_prefix", k.getKey_prefix());
row.put("user_id", k.getUser_id());
row.put("company_code", k.getCompany_code());
row.put("agent_id", k.getAgent_id());
row.put("permissions", k.getPermissions());
row.put("rate_limit", k.getRate_limit());
row.put("monthly_token_limit", k.getMonthly_token_limit());
row.put("status", k.getStatus());
row.put("last_used_at", k.getLast_used_at());
row.put("usage_count", k.getUsage_count());
row.put("total_tokens", k.getTotal_tokens());
row.put("expires_at", k.getExpires_at());
row.put("created_at", k.getCreated_at());
parseJsonField(row, "permissions");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
public AiAgentApiKey getById(long id) {
return apiKeyMapper.getById(id);
}
/** {key, plainKey} 쌍 반환 */
public CreatedKey create(ApiKeyCreateRequest req, String userId, String companyCode) {
GeneratedKey gen = generateKey();
AiAgentApiKey key = new AiAgentApiKey();
key.setName(req.getName());
key.setKey_hash(gen.hash);
key.setKey_prefix(gen.prefix);
key.setUser_id(userId);
key.setCompany_code(companyCode);
key.setAgent_id(req.getAgent_id());
key.setPermissions(toJsonString(req.getPermissions() != null ? req.getPermissions() : List.of("chat")));
key.setRate_limit(req.getRate_limit() != null ? req.getRate_limit() : 60);
key.setMonthly_token_limit(req.getMonthly_token_limit() != null ? req.getMonthly_token_limit() : 1_000_000L);
key.setExpires_at(req.getExpires_at());
apiKeyMapper.insert(key);
AiAgentApiKey saved = apiKeyMapper.getById(key.getId());
log.info("API 키 생성: {}... (by {})", gen.prefix, userId);
return new CreatedKey(saved, gen.plainKey);
}
@Transactional
public void revoke(long id, String userId) {
apiKeyMapper.delete(id, userId);
log.info("API 키 삭제: id={} (by {})", id, userId);
}
/**
* 외부 API 키 검증 — SHA-256 매칭 + 만료 체크 + last_used 갱신.
* @return null = 무효, 객체 = 유효
*/
@Transactional
public AiAgentApiKey validateKey(String plainKey) {
if (plainKey == null || !plainKey.startsWith("sk-pipe-")) return null;
String hash = sha256Hex(plainKey);
AiAgentApiKey key = apiKeyMapper.getByKeyHash(hash);
if (key == null) return null;
if (key.getExpires_at() != null && key.getExpires_at().isBefore(OffsetDateTime.now())) {
return null;
}
apiKeyMapper.updateLastUsed(key.getId());
return key;
}
public void addTokenUsage(long keyId, long tokens) {
apiKeyMapper.addTokenUsage(keyId, tokens);
}
public int countActive() {
return apiKeyMapper.countActive();
}
private GeneratedKey generateKey() {
byte[] randomBytes = new byte[32];
secureRandom.nextBytes(randomBytes);
String hex = HexFormat.of().formatHex(randomBytes);
String plainKey = "sk-pipe-" + hex;
String hash = sha256Hex(plainKey);
String prefix = plainKey.substring(0, 16);
return new GeneratedKey(plainKey, hash, prefix);
}
private String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new AiAgentException("SHA-256 미지원", e);
}
}
private String toJsonString(Object value) {
if (value == null) return null;
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new AiAgentException("JSON 직렬화 실패", e);
}
}
public record CreatedKey(AiAgentApiKey key, String plainKey) {}
private record GeneratedKey(String plainKey, String hash, String prefix) {}
}
@@ -0,0 +1,150 @@
package com.erp.ai.service;
import com.erp.ai.mapper.AiAgentConversationMapper;
import com.erp.ai.mapper.AiAgentMessageMapper;
import com.erp.ai.model.AiAgentConversation;
import com.erp.ai.model.AiAgentMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* AI 대화 모니터링 서비스.
* vexplor aiAgentConversationService.ts 1:1 포팅.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiAgentConversationService {
private final AiAgentConversationMapper conversationMapper;
private final AiAgentMessageMapper messageMapper;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public Map<String, Object> list(int page, int limit, Long agentId) {
int offset = (page - 1) * limit;
List<AiAgentConversation> conversations = conversationMapper.list(agentId, limit, offset);
int total = conversationMapper.count(agentId);
Map<String, Object> result = new HashMap<>();
result.put("conversations", conversations.stream().map(this::convToResponseMap).toList());
result.put("total", total);
return result;
}
public Map<String, Object> getById(long id) {
AiAgentConversation conv = conversationMapper.getById(id);
List<AiAgentMessage> messages = conv != null
? messageMapper.listByConversationId(id)
: List.of();
Map<String, Object> result = new HashMap<>();
result.put("conversation", conv != null ? convToResponseMap(conv) : null);
result.put("messages", messages.stream().map(this::messageToResponseMap).toList());
return result;
}
private Map<String, Object> convToResponseMap(AiAgentConversation c) {
Map<String, Object> row = new HashMap<>();
row.put("id", c.getId());
row.put("conversation_id", c.getConversation_id());
row.put("agent_id", c.getAgent_id());
row.put("user_id", c.getUser_id());
row.put("api_key_id", c.getApi_key_id());
row.put("title", c.getTitle());
row.put("message_count", c.getMessage_count());
row.put("total_tokens", c.getTotal_tokens());
row.put("status", c.getStatus());
row.put("metadata", c.getMetadata());
row.put("created_at", c.getCreated_at());
row.put("updated_at", c.getUpdated_at());
row.put("agent_name", c.getAgent_name());
row.put("display_name", c.getDisplay_name());
parseJsonField(row, "metadata");
return row;
}
private Map<String, Object> messageToResponseMap(AiAgentMessage m) {
Map<String, Object> row = new HashMap<>();
row.put("id", m.getId());
row.put("conversation_id", m.getConversation_id());
row.put("role", m.getRole());
row.put("content", m.getContent());
row.put("tool_calls", m.getTool_calls());
row.put("token_count", m.getToken_count());
row.put("metadata", m.getMetadata());
row.put("created_at", m.getCreated_at());
parseJsonField(row, "tool_calls");
parseJsonField(row, "metadata");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgentConversation createConversation(Long agentId, String userId, Long apiKeyId) {
AiAgentConversation conv = new AiAgentConversation();
conv.setConversation_id("conv-" + UUID.randomUUID());
conv.setAgent_id(agentId);
conv.setUser_id(userId);
conv.setApi_key_id(apiKeyId);
conv.setMetadata("{}");
conversationMapper.insert(conv);
return conversationMapper.getById(conv.getId());
}
@Transactional
public void updateMeta(long id, String title, String metadataJson) {
conversationMapper.updateMeta(id, title, metadataJson);
}
@Transactional
public AiAgentMessage addMessage(long conversationId, String role, String content,
int tokenCount, String toolCallsJson) {
AiAgentMessage msg = new AiAgentMessage();
msg.setConversation_id(conversationId);
msg.setRole(role);
msg.setContent(content);
msg.setToken_count(tokenCount);
msg.setTool_calls(toolCallsJson);
messageMapper.insert(msg);
conversationMapper.incrementStats(conversationId, tokenCount);
return msg;
}
@Transactional
public AiAgentMessage addMessageWithMetadata(long conversationId, String role, String content,
int tokenCount, String metadataJson) {
AiAgentMessage msg = new AiAgentMessage();
msg.setConversation_id(conversationId);
msg.setRole(role);
msg.setContent(content);
msg.setToken_count(tokenCount);
msg.setMetadata(metadataJson);
messageMapper.insert(msg);
conversationMapper.incrementStats(conversationId, tokenCount);
return msg;
}
@Transactional
public void delete(long id) {
conversationMapper.delete(id);
}
}

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