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:
@@ -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` |
|
||||
|
||||
|
||||
@@ -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` 기반으로 자동 판별
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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` 등
|
||||
Generated
-3455
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, {});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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: 권한 없음
|
||||
*/
|
||||
@@ -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
Reference in New Issue
Block a user