Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
c56f434ff1
|
|
@ -0,0 +1,25 @@
|
|||
# AI Assistant API (VEXPLOR 내장) - 환경 변수
|
||||
# 이 파일을 .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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 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"]
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# AI 어시스턴트 API (VEXPLOR 내장)
|
||||
|
||||
VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
|
||||
|
||||
## 동작 방식
|
||||
|
||||
- **프론트(9771)** → `/api/ai/v1/*` 호출
|
||||
- **Next.js** → `8080/api/ai/v1/*` 로 rewrite
|
||||
- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스**
|
||||
|
||||
따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 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` 등
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "ai-assistant-api",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Assistant API (VEXPLOR 내장) - 포트 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
// 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();
|
||||
// VEXPLOR 내장 시 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;
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
// 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: '로그아웃되었습니다.',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// 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);
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// 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();
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// 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();
|
||||
};
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// 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, {});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
// 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;
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
// 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: 권한 없음
|
||||
*/
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -3318,6 +3319,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
"version": "1.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/imap": {
|
||||
"version": "0.8.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||
|
|
@ -4419,7 +4429,6 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
|
|
@ -6154,7 +6163,6 @@
|
|||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
|
|
@ -6887,6 +6895,20 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -6900,6 +6922,29 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
|
||||
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
|
|
@ -7208,7 +7253,6 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -7238,7 +7282,6 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
|
|
@ -7269,7 +7312,6 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
|
|
@ -7294,6 +7336,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
|
|
@ -8566,7 +8617,6 @@
|
|||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
|
|
@ -9388,7 +9438,6 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -9946,6 +9995,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
|
|
@ -10824,7 +10879,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
|
|||
|
|
@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
|||
// ============================================
|
||||
|
||||
// 처리되지 않은 Promise 거부 핸들러
|
||||
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
|
||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
// 프로세스를 종료하지 않고 로깅만 수행
|
||||
// 심각한 에러의 경우 graceful shutdown 고려
|
||||
});
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
(reason: Error | any, promise: Promise<any>) => {
|
||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
// 프로세스를 종료하지 않고 로깅만 수행
|
||||
// 심각한 에러의 경우 graceful shutdown 고려
|
||||
},
|
||||
);
|
||||
|
||||
// 처리되지 않은 예외 핸들러
|
||||
process.on("uncaughtException", (error: Error) => {
|
||||
|
|
@ -38,13 +41,16 @@ process.on("uncaughtException", (error: Error) => {
|
|||
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
|
||||
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// SIGINT 시그널 처리 (Ctrl+C)
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
|
|
@ -112,7 +118,9 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
|||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import entitySearchRoutes, {
|
||||
entityOptionsRouter,
|
||||
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
|
|
@ -128,6 +136,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
|
|||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
|
|
@ -152,7 +161,7 @@ app.use(
|
|||
], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
|
|
@ -175,13 +184,13 @@ app.use(
|
|||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
express.static(path.join(process.cwd(), "uploads")),
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
|
|
@ -201,7 +210,7 @@ app.use(
|
|||
],
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 200,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Rate Limiting (개발 환경에서는 완화)
|
||||
|
|
@ -318,6 +327,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테
|
|||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
@ -406,6 +416,14 @@ app.listen(PORT, HOST, async () => {
|
|||
} catch (error) {
|
||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
|
||||
try {
|
||||
const { startAiAssistant } = await import("./utils/startAiAssistant");
|
||||
startAiAssistant();
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* AI 어시스턴트 API 프록시
|
||||
* - /api/ai/v1/* 요청을 AI 서비스(기본 3100 포트)로 전달
|
||||
* - VEXPLOR와 같은 서비스로 쓰려면: 프론트(9771) → 백엔드(8080) → 여기서 3100으로 프록시
|
||||
*/
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
const AI_SERVICE_URL =
|
||||
process.env.AI_ASSISTANT_SERVICE_URL || "http://127.0.0.1:3100";
|
||||
|
||||
const aiAssistantProxy: RequestHandler = createProxyMiddleware({
|
||||
target: AI_SERVICE_URL,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { "^/api/ai/v1": "/api/v1" },
|
||||
// 대상 서비스 미기동 시 502 등 에러 처리 (v3 타입에 없을 수 있음)
|
||||
onError: (_err, _req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "AI_SERVICE_UNAVAILABLE",
|
||||
message:
|
||||
"AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof createProxyMiddleware>[0]);
|
||||
|
||||
export default aiAssistantProxy;
|
||||
|
|
@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
|
|||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean {
|
|||
return SAFE_IDENTIFIER.test(name);
|
||||
}
|
||||
|
||||
interface AutoGenMappingInfo {
|
||||
numberingRuleId: string;
|
||||
targetColumn: string;
|
||||
showResultModal?: boolean;
|
||||
}
|
||||
|
||||
interface HiddenMappingInfo {
|
||||
valueSource: "json_extract" | "db_column" | "static";
|
||||
targetColumn: string;
|
||||
staticValue?: string;
|
||||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
sourceDbColumn?: string;
|
||||
}
|
||||
|
||||
interface MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
autoGenMappings?: AutoGenMappingInfo[];
|
||||
hiddenMappings?: HiddenMappingInfo[];
|
||||
}
|
||||
|
||||
interface StatusConditionRule {
|
||||
|
|
@ -44,7 +62,8 @@ interface StatusChangeRuleBody {
|
|||
}
|
||||
|
||||
interface ExecuteActionBody {
|
||||
action: string;
|
||||
action?: string;
|
||||
tasks?: TaskBody[];
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
|
|
@ -54,6 +73,36 @@ interface ExecuteActionBody {
|
|||
field?: MappingInfo | null;
|
||||
};
|
||||
statusChanges?: StatusChangeRuleBody[];
|
||||
cartChanges?: {
|
||||
toCreate?: Record<string, unknown>[];
|
||||
toUpdate?: Record<string, unknown>[];
|
||||
toDelete?: (string | number)[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TaskBody {
|
||||
id: string;
|
||||
type: string;
|
||||
targetTable?: string;
|
||||
targetColumn?: string;
|
||||
operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
|
||||
valueSource?: "fixed" | "linked" | "reference";
|
||||
fixedValue?: string;
|
||||
sourceField?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
referenceJoinKey?: string;
|
||||
conditionalValue?: ConditionalValueRule;
|
||||
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
|
||||
compareColumn?: string;
|
||||
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
compareWith?: string;
|
||||
dbThenValue?: string;
|
||||
dbElseValue?: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
|
|
@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
||||
const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
|
||||
const items = data?.items ?? [];
|
||||
const fieldValues = data?.fieldValues ?? {};
|
||||
|
||||
logger.info("[pop/execute-action] 요청", {
|
||||
action,
|
||||
action: action ?? "task-list",
|
||||
companyCode,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||
hasMappings: !!mappings,
|
||||
statusChangeCount: statusChanges?.length ?? 0,
|
||||
taskCount: tasks?.length ?? 0,
|
||||
hasCartChanges: !!cartChanges,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let processedCount = 0;
|
||||
let insertedCount = 0;
|
||||
let deletedCount = 0;
|
||||
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
||||
|
||||
if (action === "inbound-confirm") {
|
||||
// ======== v2: tasks 배열 기반 처리 ========
|
||||
if (tasks && tasks.length > 0) {
|
||||
for (const task of tasks) {
|
||||
switch (task.type) {
|
||||
case "data-save": {
|
||||
// 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
|
||||
if (!isSafeIdentifier(cardMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(item[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (fieldMapping?.targetTable === cardMapping.targetTable) {
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
if (columns.includes(`"${targetColumn}"`)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const allHidden = [
|
||||
...(fieldMapping?.hiddenMappings ?? []),
|
||||
...(cardMapping?.hiddenMappings ?? []),
|
||||
];
|
||||
for (const hm of allHidden) {
|
||||
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||
let value: unknown = null;
|
||||
if (hm.valueSource === "static") {
|
||||
value = hm.staticValue ?? null;
|
||||
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||
const jsonCol = item[hm.sourceJsonColumn];
|
||||
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||
} else if (typeof jsonCol === "string") {
|
||||
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||
}
|
||||
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||
}
|
||||
columns.push(`"${hm.targetColumn}"`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await client.query(
|
||||
`INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values,
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-update": {
|
||||
if (!task.targetTable || !task.targetColumn) break;
|
||||
if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break;
|
||||
|
||||
const opType = task.operationType ?? "assign";
|
||||
const valSource = task.valueSource ?? "fixed";
|
||||
const lookupMode = task.lookupMode ?? "auto";
|
||||
|
||||
let itemField: string;
|
||||
let pkColumn: string;
|
||||
|
||||
if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) {
|
||||
if (!isSafeIdentifier(task.manualPkColumn)) break;
|
||||
itemField = task.manualItemField;
|
||||
pkColumn = task.manualPkColumn;
|
||||
} else if (task.targetTable === "cart_items") {
|
||||
itemField = "__cart_id";
|
||||
pkColumn = "id";
|
||||
} else {
|
||||
itemField = "__cart_row_key";
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[task.targetTable],
|
||||
);
|
||||
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||
}
|
||||
|
||||
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||
if (lookupValues.length === 0) break;
|
||||
|
||||
if (opType === "conditional" && task.conditionalValue) {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
const thenVal = task.dbThenValue ?? "";
|
||||
const elseVal = task.dbElseValue ?? "";
|
||||
const op = task.compareOperator;
|
||||
const validOps = ["=", "!=", ">", "<", ">=", "<="];
|
||||
if (!validOps.includes(op)) break;
|
||||
|
||||
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
||||
|
||||
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
||||
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||
);
|
||||
processedCount += lookupValues.length;
|
||||
} else {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
let value: unknown;
|
||||
|
||||
if (valSource === "linked") {
|
||||
value = item[task.sourceField ?? ""] ?? null;
|
||||
} else {
|
||||
value = task.fixedValue ?? "";
|
||||
}
|
||||
|
||||
let setSql: string;
|
||||
if (opType === "add") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`;
|
||||
} else if (opType === "subtract") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`;
|
||||
} else if (opType === "multiply") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`;
|
||||
} else if (opType === "divide") {
|
||||
setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`;
|
||||
} else {
|
||||
setSql = `"${task.targetColumn}" = $1`;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] data-update 실행", {
|
||||
table: task.targetTable,
|
||||
column: task.targetColumn,
|
||||
opType,
|
||||
count: lookupValues.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-delete": {
|
||||
if (!task.targetTable) break;
|
||||
if (!isSafeIdentifier(task.targetTable)) break;
|
||||
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[task.targetTable],
|
||||
);
|
||||
const pkCol = pkResult.rows[0]?.attname || "id";
|
||||
const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean);
|
||||
|
||||
if (deleteKeys.length > 0) {
|
||||
const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", ");
|
||||
await client.query(
|
||||
`DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`,
|
||||
[companyCode, ...deleteKeys],
|
||||
);
|
||||
deletedCount += deleteKeys.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "cart-save": {
|
||||
// cartChanges 처리 (M-9에서 확장)
|
||||
if (!cartChanges) break;
|
||||
const { toCreate, toUpdate, toDelete } = cartChanges;
|
||||
|
||||
if (toCreate && toCreate.length > 0) {
|
||||
for (const item of toCreate) {
|
||||
const cols = Object.keys(item).filter(isSafeIdentifier);
|
||||
if (cols.length === 0) continue;
|
||||
const allCols = ["company_code", ...cols.map((c) => `"${c}"`)];
|
||||
const allVals = [companyCode, ...cols.map((c) => item[c])];
|
||||
const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await client.query(
|
||||
`INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`,
|
||||
allVals,
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toUpdate && toUpdate.length > 0) {
|
||||
for (const item of toUpdate) {
|
||||
const id = item.id;
|
||||
if (!id) continue;
|
||||
const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c));
|
||||
if (cols.length === 0) continue;
|
||||
const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", ");
|
||||
await client.query(
|
||||
`UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode, ...cols.map((c) => item[c])],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete && toDelete.length > 0) {
|
||||
const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", ");
|
||||
await client.query(
|
||||
`DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||
[companyCode, ...toDelete],
|
||||
);
|
||||
deletedCount += toDelete.length;
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] cart-save 실행", {
|
||||
created: toCreate?.length ?? 0,
|
||||
updated: toUpdate?.length ?? 0,
|
||||
deleted: toDelete?.length ?? 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type });
|
||||
}
|
||||
}
|
||||
}
|
||||
// ======== v1 레거시: action 기반 처리 ========
|
||||
else if (action === "inbound-confirm") {
|
||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
|
@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
}
|
||||
}
|
||||
|
||||
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
||||
const allHidden = [
|
||||
...(fieldMapping?.hiddenMappings ?? []),
|
||||
...(cardMapping?.hiddenMappings ?? []),
|
||||
];
|
||||
for (const hm of allHidden) {
|
||||
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||
|
||||
let value: unknown = null;
|
||||
if (hm.valueSource === "static") {
|
||||
value = hm.staticValue ?? null;
|
||||
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||
const jsonCol = item[hm.sourceJsonColumn];
|
||||
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||
} else if (typeof jsonCol === "string") {
|
||||
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||
}
|
||||
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||
}
|
||||
|
||||
columns.push(`"${hm.targetColumn}"`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId,
|
||||
companyCode,
|
||||
{ ...fieldValues, ...item },
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
logger.info("[pop/execute-action] 채번 완료", {
|
||||
ruleId: ag.numberingRuleId,
|
||||
targetColumn: ag.targetColumn,
|
||||
generatedCode,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 채번 실패", {
|
||||
ruleId: ag.numberingRuleId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
|
|
@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/execute-action] 완료", {
|
||||
action,
|
||||
action: action ?? "task-list",
|
||||
companyCode,
|
||||
processedCount,
|
||||
insertedCount,
|
||||
deletedCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
|
||||
data: { processedCount, insertedCount, deletedCount, generatedCodes },
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* AI 어시스턴트 서비스를 자식 프로세스로 기동
|
||||
* - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료 (한 번에 킬)
|
||||
*/
|
||||
import path from "path";
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const AI_PORT = process.env.AI_ASSISTANT_SERVICE_PORT || "3100";
|
||||
|
||||
let aiAssistantProcess: ChildProcess | null = null;
|
||||
|
||||
/** ERP-node/ai-assistant 경로 (backend-node 기준 상대) */
|
||||
function getAiAssistantDir(): string {
|
||||
return path.resolve(process.cwd(), "..", "ai-assistant");
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 어시스턴트 서비스 기동 (있으면 띄움, 실패해도 backend는 계속 동작)
|
||||
*/
|
||||
export function startAiAssistant(): void {
|
||||
const aiDir = getAiAssistantDir();
|
||||
const appPath = path.join(aiDir, "src", "app.js");
|
||||
|
||||
try {
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(appPath)) {
|
||||
logger.info(`⏭️ AI 어시스턴트 스킵 (경로 없음: ${appPath})`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
aiAssistantProcess = spawn("node", ["src/app.js"], {
|
||||
cwd: aiDir,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PORT: AI_PORT },
|
||||
shell: true, // Windows에서 node 경로 인식
|
||||
});
|
||||
|
||||
aiAssistantProcess.on("error", (err) => {
|
||||
logger.warn(`⚠️ AI 어시스턴트 프로세스 에러: ${err.message}`);
|
||||
});
|
||||
|
||||
aiAssistantProcess.on("exit", (code, signal) => {
|
||||
aiAssistantProcess = null;
|
||||
if (code != null && code !== 0) {
|
||||
logger.warn(`⚠️ AI 어시스턴트 종료 (code=${code}, signal=${signal})`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`🤖 AI 어시스턴트 서비스 기동 (포트 ${AI_PORT}, cwd: ${aiDir})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 어시스턴트 프로세스 종료 (SIGTERM/SIGINT 시 호출)
|
||||
*/
|
||||
export function stopAiAssistant(): void {
|
||||
if (aiAssistantProcess && aiAssistantProcess.kill) {
|
||||
aiAssistantProcess.kill("SIGTERM");
|
||||
aiAssistantProcess = null;
|
||||
logger.info("🤖 AI 어시스턴트 프로세스 종료");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||||
import type { ApiKeyItem } from "@/lib/api/aiAssistant";
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AiAssistantApiKeysPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newKeyDialogOpen, setNewKeyDialogOpen] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys();
|
||||
}, []);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await aiAssistantApi.get("/api-keys");
|
||||
setApiKeys(res.data?.data ?? []);
|
||||
} catch {
|
||||
toast.error("API 키 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast.error("키 이름을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await aiAssistantApi.post("/api-keys", { name: newKeyName });
|
||||
setNewKey((res.data?.data as { key?: string })?.key ?? "");
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyDialogOpen(true);
|
||||
setNewKeyName("");
|
||||
loadApiKeys();
|
||||
toast.success("API 키가 생성되었습니다.");
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data
|
||||
?.error?.message
|
||||
: null;
|
||||
toast.error(msg ?? "API 키 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeApiKey = async (id: number) => {
|
||||
if (!confirm("이 API 키를 폐기하시겠습니까?")) return;
|
||||
try {
|
||||
await aiAssistantApi.delete(`/api-keys/${id}`);
|
||||
loadApiKeys();
|
||||
toast.success("API 키가 폐기되었습니다.");
|
||||
} catch {
|
||||
toast.error("API 키 폐기에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success("클립보드에 복사되었습니다.");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("복사에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || "http://localhost:3100/api/v1"
|
||||
: "";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API 키 관리</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
외부 시스템에서 AI Assistant API를 사용하기 위한 키를 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 API 키
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 API 키 생성</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 API 키를 생성합니다. 키는 한 번만 표시되므로 안전하게 보관하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">키 이름</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder="예: Production Server"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={createApiKey} disabled={creating}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
생성
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={newKeyDialogOpen} onOpenChange={setNewKeyDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API 키가 생성되었습니다</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 키는 다시 표시되지 않습니다. 안전한 곳에 복사하여 보관하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={newKey}
|
||||
readOnly
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={() => setShowKey(!showKey)}>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => copyToClipboard(newKey)}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setNewKeyDialogOpen(false)}>확인</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 키 목록</CardTitle>
|
||||
<CardDescription>발급된 모든 API 키를 확인하고 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Key className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="text-lg font-medium">API 키가 없습니다</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">새 API 키를 생성하여 시작하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>키</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>사용량</TableHead>
|
||||
<TableHead>마지막 사용</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-medium">{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted rounded px-2 py-1 text-sm">
|
||||
{key.keyPrefix}...
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => copyToClipboard(key.keyPrefix + "...")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={key.status === "active" ? "success" : "secondary"}>
|
||||
{key.status === "active" ? "활성" : "폐기됨"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{(key.usageCount ?? 0).toLocaleString()} 토큰</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(key.createdAt).toLocaleDateString("ko-KR")}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{key.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
onClick={() => revokeApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 사용 방법</CardTitle>
|
||||
<CardDescription>
|
||||
발급받은 API 키를 Authorization 헤더에 포함하여 요청하세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-4 text-sm">
|
||||
{`curl -X POST ${baseUrl}/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-d '{"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hello!"}]}'`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DEFAULT_BASE = "http://localhost:3100/api/v1";
|
||||
const PRESETS = [
|
||||
{ name: "채팅 완성", method: "POST", endpoint: "/chat/completions", body: '{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"안녕하세요!"}],"temperature":0.7}' },
|
||||
{ name: "모델 목록", method: "GET", endpoint: "/models", body: "" },
|
||||
{ name: "사용량", method: "GET", endpoint: "/usage", body: "" },
|
||||
{ name: "API 키 목록", method: "GET", endpoint: "/api-keys", body: "" },
|
||||
];
|
||||
|
||||
export default function AiAssistantApiTestPage() {
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
typeof window !== "undefined" ? (process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || DEFAULT_BASE) : DEFAULT_BASE
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [method, setMethod] = useState("POST");
|
||||
const [endpoint, setEndpoint] = useState("/chat/completions");
|
||||
const [body, setBody] = useState(PRESETS[0].body);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [response, setResponse] = useState<{ status: number; statusText: string; data: unknown } | null>(null);
|
||||
const [responseTime, setResponseTime] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const apply = (p: (typeof PRESETS)[0]) => {
|
||||
setMethod(p.method);
|
||||
setEndpoint(p.endpoint);
|
||||
setBody(p.body);
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
setLoading(true);
|
||||
setResponse(null);
|
||||
setResponseTime(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const opt: RequestInit = { method, headers };
|
||||
if (method !== "GET" && body.trim()) {
|
||||
try {
|
||||
JSON.parse(body);
|
||||
opt.body = body;
|
||||
} catch {
|
||||
toast.error("JSON 형식 오류");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${baseUrl}${endpoint}`, opt);
|
||||
const elapsed = Date.now() - start;
|
||||
setResponseTime(elapsed);
|
||||
const ct = res.headers.get("content-type");
|
||||
const data = ct?.includes("json") ? await res.json() : await res.text();
|
||||
setResponse({ status: res.status, statusText: res.statusText, data });
|
||||
toast.success(res.ok ? `성공 ${res.status}` : `실패 ${res.status}`);
|
||||
} catch (e) {
|
||||
setResponseTime(Date.now() - start);
|
||||
setResponse({ status: 0, statusText: "Network Error", data: { error: String(e) } });
|
||||
toast.error("네트워크 오류");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyRes = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(response?.data, null, 2));
|
||||
setCopied(true);
|
||||
toast.success("복사됨");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const statusV = (s: number) => (s >= 200 && s < 300 ? "success" : s >= 400 ? "destructive" : "secondary");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API 테스트</h1>
|
||||
<p className="text-muted-foreground mt-1">API를 직접 호출하여 테스트합니다.</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">API 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Base URL</Label>
|
||||
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API 키 또는 JWT</Label>
|
||||
<Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-xxx" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">빠른 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((p, i) => (
|
||||
<Button key={i} variant="outline" size="sm" onClick={() => apply(p)}>
|
||||
<Badge variant="secondary" className="mr-2 text-xs">{p.method}</Badge>
|
||||
{p.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">요청</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
{method !== "GET" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Body (JSON)</Label>
|
||||
<Textarea value={body} onChange={(e) => setBody(e.target.value)} className="font-mono text-sm min-h-[180px]" />
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={send} disabled={loading}>
|
||||
{loading ? "요청 중..." : "요청 보내기"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">응답</CardTitle>
|
||||
{response && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusV(response.status)}>{response.status} {response.statusText}</Badge>
|
||||
{responseTime != null && <Badge variant="outline">{responseTime}ms</Badge>}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={copyRes}>
|
||||
{copied ? "✓" : "복사"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!response ? (
|
||||
<p className="text-muted-foreground py-12 text-center">요청을 보내면 응답이 표시됩니다.</p>
|
||||
) : (
|
||||
<pre className="bg-muted max-h-[500px] overflow-auto rounded-lg p-4 text-sm font-mono whitespace-pre-wrap">
|
||||
{typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||||
import { Send, Loader2, Bot, User, Trash2, Settings2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ChatMessage = { role: "user" | "assistant"; content: string };
|
||||
type ModelItem = { id: string };
|
||||
|
||||
export default function AiAssistantChatPage() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [models, setModels] = useState<ModelItem[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("gemini-2.0-flash");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
aiAssistantApi.get("/models").then((res) => {
|
||||
const list = (res.data?.data as ModelItem[]) ?? [];
|
||||
setModels(list);
|
||||
if (list.length && !list.some((m) => m.id === selectedModel)) setSelectedModel(list[0].id);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || loading) return;
|
||||
const userMsg: ChatMessage = { role: "user", content: input.trim() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await aiAssistantApi.post("/chat/completions", {
|
||||
model: selectedModel,
|
||||
messages: [...messages, userMsg].map((m) => ({ role: m.role, content: m.content })),
|
||||
});
|
||||
const content = (res.data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content ?? "";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content }]);
|
||||
} catch (err: unknown) {
|
||||
const msg = err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data?.error?.message
|
||||
: null;
|
||||
toast.error(msg ?? "AI 응답 실패");
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI 채팅</h1>
|
||||
<p className="text-muted-foreground mt-1">AI Assistant와 대화하세요.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
<SelectValue placeholder="모델 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>{m.id}</SelectItem>
|
||||
))}
|
||||
{models.length === 0 && <SelectItem value="gemini-2.0-flash">gemini-2.0-flash</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={() => setMessages([])}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="flex flex-1 flex-col overflow-hidden">
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="bg-primary/10 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Bot className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">AI Assistant</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm">무엇이든 물어보세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={cn("flex gap-3", msg.role === "user" && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className={cn(msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
{msg.role === "user" ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("max-w-[80%] rounded-lg px-4 py-2", msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
<p className="whitespace-pre-wrap text-sm">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className="bg-muted"><Bot className="h-4 w-4" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="rounded-lg bg-muted px-4 py-2"><Loader2 className="h-4 w-4 animate-spin" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<CardContent className="border-t p-4">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), handleSubmit(e as unknown as React.FormEvent))}
|
||||
placeholder="메시지 입력 (Shift+Enter 줄바꿈)"
|
||||
className="max-h-[200px] min-h-[60px] resize-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button type="submit" size="icon" className="h-[60px] w-[60px]" disabled={loading || !input.trim()}>
|
||||
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getAiAssistantAuth, aiAssistantApi } from "@/lib/api/aiAssistant";
|
||||
import type { UsageSummary, ApiKeyItem, AdminStats } from "@/lib/api/aiAssistant";
|
||||
import { BarChart3, Key, Zap, TrendingUp, Loader2, AlertCircle, Users, Cpu } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AiAssistantDashboardPage() {
|
||||
const auth = getAiAssistantAuth();
|
||||
const user = auth?.user;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const usageRes = await aiAssistantApi.get("/usage");
|
||||
setUsage(usageRes.data?.data ?? null);
|
||||
const keysRes = await aiAssistantApi.get("/api-keys");
|
||||
setApiKeys(keysRes.data?.data ?? []);
|
||||
if (isAdmin) {
|
||||
const statsRes = await aiAssistantApi.get("/admin/stats");
|
||||
setStats(statsRes.data?.data ?? null);
|
||||
}
|
||||
} catch {
|
||||
toast.error("데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const monthlyTokens = usage?.usage?.monthly?.totalTokens ?? 0;
|
||||
const monthlyLimit = usage?.limit?.monthly ?? 0;
|
||||
const usagePercent = monthlyLimit > 0 ? Math.round((monthlyTokens / monthlyLimit) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">대시보드</h1>
|
||||
<p className="text-muted-foreground mt-1">안녕하세요, {user?.name || user?.email}님!</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 사용량</CardTitle>
|
||||
<Zap className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(usage?.usage?.today?.tokens ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">토큰</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">이번 달 사용량</CardTitle>
|
||||
<BarChart3 className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{monthlyTokens.toLocaleString()}</div>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
/ {monthlyLimit.toLocaleString()} 토큰
|
||||
</p>
|
||||
<Progress value={usagePercent} className="h-2" />
|
||||
<p className="text-muted-foreground mt-1 text-right text-xs">{usagePercent}% 사용</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 요청 수</CardTitle>
|
||||
<TrendingUp className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(usage?.usage?.today?.requests ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">회</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">활성 API 키</CardTitle>
|
||||
<Key className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{apiKeys.filter((k) => k.status === "active").length}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">개</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isAdmin && stats && (
|
||||
<Card className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground">
|
||||
<CardHeader>
|
||||
<CardTitle>시스템 현황</CardTitle>
|
||||
<CardDescription className="text-primary-foreground/70">전체 시스템 통계</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm opacity-80">전체 사용자</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.users?.total ?? 0}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm opacity-80">활성 사용자</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.users?.active ?? 0}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
<span className="text-sm opacity-80">전체 API 키</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.apiKeys?.total ?? 0}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span className="text-sm opacity-80">활성 프로바이더</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.providers?.active ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>내 API 키</CardTitle>
|
||||
<CardDescription>발급받은 API 키 목록</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground">API 키가 없습니다.</p>
|
||||
<p className="text-muted-foreground text-sm">새 키를 발급받으세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{apiKeys.slice(0, 5).map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="bg-card flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{key.name}</p>
|
||||
<p className="text-muted-foreground font-mono text-sm">{key.keyPrefix}...</p>
|
||||
</div>
|
||||
<Badge variant={key.status === "active" ? "success" : "secondary"}>
|
||||
{key.status === "active" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||||
import type { UsageLogItem } from "@/lib/api/aiAssistant";
|
||||
import { History, Loader2, MessageSquare, Clock, Zap, CheckCircle, XCircle } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AiAssistantHistoryPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logs, setLogs] = useState<UsageLogItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [page]);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await aiAssistantApi.get(`/usage/logs?page=${page}&limit=20`);
|
||||
const data = res.data?.data as { logs?: UsageLogItem[]; pagination?: { totalPages?: number } };
|
||||
setLogs(data?.logs ?? []);
|
||||
setTotalPages(data?.pagination?.totalPages ?? 1);
|
||||
} catch {
|
||||
toast.error("대화 이력을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">대화 이력</h1>
|
||||
<p className="text-muted-foreground mt-1">AI Assistant와의 대화 기록을 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 호출 로그</CardTitle>
|
||||
<CardDescription>최근 API 호출 기록</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<History className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="text-lg font-medium">대화 이력이 없습니다</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">AI 채팅을 시작하면 이력이 표시됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>프로바이더</TableHead>
|
||||
<TableHead>모델</TableHead>
|
||||
<TableHead>토큰</TableHead>
|
||||
<TableHead>비용</TableHead>
|
||||
<TableHead>응답시간</TableHead>
|
||||
<TableHead>일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
{log.success ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
성공
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
실패
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.providerName}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.modelName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="text-muted-foreground h-3 w-3" />
|
||||
<span>{(log.totalTokens ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
입력: {log.promptTokens ?? 0} / 출력: {log.completionTokens ?? 0}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${(log.costUsd ?? 0).toFixed(6)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="text-muted-foreground h-3 w-3" />
|
||||
<span>{log.responseTimeMs ?? 0}ms</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(log.createdAt).toLocaleString("ko-KR")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getAiAssistantAuth,
|
||||
setAiAssistantAuth,
|
||||
loginAiAssistant,
|
||||
} from "@/lib/api/aiAssistant";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function AIAssistantLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [auth, setAuth] = useState<ReturnType<typeof getAiAssistantAuth>>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setAuth(getAiAssistantAuth());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginAiAssistant(email, password);
|
||||
setAuth(getAiAssistantAuth());
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data
|
||||
?.error?.message
|
||||
: null;
|
||||
setError(msg || "로그인에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAiAssistantAuth(null);
|
||||
setAuth(null);
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] max-w-sm flex-col justify-center p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 어시스턴트 로그인</CardTitle>
|
||||
<CardDescription>
|
||||
AI 서비스(API 키, 사용량, 채팅 등)를 사용하려면 로그인하세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@admin.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1" disabled={loading}>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin">취소</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-2 border-b pb-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{auth.user?.name || auth.user?.email} (AI 서비스)
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
/** AI 어시스턴트 진입 시 대시보드로 이동 */
|
||||
export default function AIAssistantPage() {
|
||||
redirect("/admin/aiAssistant/dashboard");
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||||
import type { UsageSummary } from "@/lib/api/aiAssistant";
|
||||
import { BarChart3, Calendar, Loader2, TrendingUp, Zap, DollarSign } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DailyUsageItem {
|
||||
date?: string;
|
||||
totalTokens?: number;
|
||||
requestCount?: number;
|
||||
}
|
||||
|
||||
export default function AiAssistantUsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageItem[]>([]);
|
||||
const [period, setPeriod] = useState("7");
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
}, [period]);
|
||||
|
||||
const loadUsage = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usageRes, dailyRes] = await Promise.all([
|
||||
aiAssistantApi.get("/usage"),
|
||||
aiAssistantApi.get(`/usage/daily?days=${period}`),
|
||||
]);
|
||||
setUsage(usageRes.data?.data ?? null);
|
||||
setDailyUsage((dailyRes.data?.data as { usage?: DailyUsageItem[] })?.usage ?? []);
|
||||
} catch {
|
||||
toast.error("사용량 데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const todayTokens = usage?.usage?.today?.tokens ?? 0;
|
||||
const todayRequests = usage?.usage?.today?.requests ?? 0;
|
||||
const monthlyTokens = usage?.usage?.monthly?.totalTokens ?? 0;
|
||||
const monthlyCost = usage?.usage?.monthly?.totalCost ?? 0;
|
||||
const monthlyLimit = usage?.limit?.monthly ?? 0;
|
||||
const usagePercent = monthlyLimit > 0 ? Math.round((monthlyTokens / monthlyLimit) * 100) : 0;
|
||||
const maxTokens = Math.max(...dailyUsage.map((d) => d.totalTokens ?? 0), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">사용량</h1>
|
||||
<p className="text-muted-foreground mt-1">API 사용량 및 비용을 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 토큰</CardTitle>
|
||||
<Zap className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{todayTokens.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 요청</CardTitle>
|
||||
<TrendingUp className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{todayRequests.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">이번 달 토큰</CardTitle>
|
||||
<BarChart3 className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{monthlyTokens.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">이번 달 비용</CardTitle>
|
||||
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${monthlyCost.toFixed(4)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>일별 사용량</CardTitle>
|
||||
<CardDescription>기간별 토큰 사용량 추이</CardDescription>
|
||||
</div>
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">최근 7일</SelectItem>
|
||||
<SelectItem value="14">최근 14일</SelectItem>
|
||||
<SelectItem value="30">최근 30일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dailyUsage.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground">사용 기록이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{dailyUsage.map((day, idx) => (
|
||||
<div key={day.date ?? idx} className="flex items-center gap-4">
|
||||
<div className="text-muted-foreground w-20 text-sm">
|
||||
{day.date
|
||||
? new Date(day.date).toLocaleDateString("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-muted h-8 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="bg-primary h-full rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
width: `${((day.totalTokens ?? 0) / maxTokens) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-28 text-right">
|
||||
<span className="text-sm font-medium">
|
||||
{(day.totalTokens ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1 text-xs">토큰</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground w-16 text-right text-sm">
|
||||
{day.requestCount ?? 0}회
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground">
|
||||
<CardContent className="pt-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
현재 플랜: {(usage?.plan ?? "FREE").toUpperCase()}
|
||||
</h3>
|
||||
<p className="text-primary-foreground/70">
|
||||
월간 한도: {monthlyLimit > 0 ? monthlyLimit.toLocaleString() : "무제한"} 토큰
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-bold">{usagePercent}%</p>
|
||||
<p className="text-primary-foreground/70 text-sm">사용률</p>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={usagePercent} className="bg-primary-foreground/20 h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
|
||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
|
||||
|
|
@ -80,6 +80,20 @@ export default function AdminPage() {
|
|||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/aiAssistant" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Bot className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">AI 어시스턴트</h3>
|
||||
<p className="text-muted-foreground text-sm">AI 채팅 및 LLM 연동 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { BarcodeListTable } from "@/components/barcode/BarcodeListTable";
|
||||
import { Plus, Search, RotateCcw } from "lucide-react";
|
||||
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
|
||||
import { Plus, Search, RotateCcw, Scan } from "lucide-react";
|
||||
import { useBarcodeList } from "@/hooks/useBarcodeList";
|
||||
|
||||
export default function BarcodeLabelManagementPage() {
|
||||
const router = useRouter();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [scanModalOpen, setScanModalOpen] = useState(false);
|
||||
const [scannedBarcode, setScannedBarcode] = useState<string | null>(null);
|
||||
|
||||
const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList();
|
||||
|
||||
|
|
@ -74,6 +77,33 @@ export default function BarcodeLabelManagementPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카메라 스캔: 바코드 값을 텍스트로 추출해 표시 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Scan className="h-5 w-5" />
|
||||
바코드 스캔
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
카메라로 바코드를 스캔하면 추출된 값을 아래에 텍스트로 표시합니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={() => setScanModalOpen(true)} variant="outline" className="gap-2">
|
||||
<Scan className="h-4 w-4" />
|
||||
카메라로 바코드 스캔
|
||||
</Button>
|
||||
{scannedBarcode ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-muted-foreground mb-1 text-sm">추출된 바코드 값</p>
|
||||
<p className="font-mono text-lg font-semibold break-all">{scannedBarcode}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">아직 스캔한 바코드가 없습니다. 위 버튼으로 스캔해 보세요.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
|
|
@ -95,6 +125,18 @@ export default function BarcodeLabelManagementPage() {
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BarcodeScanModal
|
||||
open={scanModalOpen}
|
||||
onOpenChange={setScanModalOpen}
|
||||
targetField="바코드 값"
|
||||
barcodeFormat="all"
|
||||
autoSubmit={false}
|
||||
onScanSuccess={(barcode) => {
|
||||
setScannedBarcode(barcode);
|
||||
setScanModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/** 작업 영역에 라벨이 들어가도록 스케일 (최소 0.5=작게 맞춤, 최대 3) */
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
export function BarcodeDesignerCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const {
|
||||
widthMm,
|
||||
heightMm,
|
||||
|
|
@ -22,17 +28,45 @@ export function BarcodeDesignerCanvas() {
|
|||
const widthPx = widthMm * MM_TO_PX;
|
||||
const heightPx = heightMm * MM_TO_PX;
|
||||
|
||||
// 컨테이너 크기에 맞춰 캔버스 스케일 계산 (라벨이 너무 작게 보이지 않도록)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || widthPx <= 0 || heightPx <= 0) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const w = el.clientWidth - 48;
|
||||
const h = el.clientHeight - 48;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const scaleX = w / widthPx;
|
||||
const scaleY = h / heightPx;
|
||||
const fitScale = Math.min(scaleX, scaleY);
|
||||
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
|
||||
setScale(s);
|
||||
});
|
||||
observer.observe(el);
|
||||
const w = el.clientWidth - 48;
|
||||
const h = el.clientHeight - 48;
|
||||
if (w > 0 && h > 0) {
|
||||
const scaleX = w / widthPx;
|
||||
const scaleY = h / heightPx;
|
||||
const fitScale = Math.min(scaleX, scaleY);
|
||||
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
|
||||
setScale(s);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [widthPx, heightPx]);
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
accept: "barcode-component",
|
||||
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvasEl = canvasRef.current;
|
||||
if (!canvasEl) return;
|
||||
const offset = monitor.getClientOffset();
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
if (!offset) return;
|
||||
|
||||
let x = offset.x - rect.left;
|
||||
let y = offset.y - rect.top;
|
||||
// 드롭 시 요소 중앙이 커서에 오도록 보정
|
||||
// 스케일 적용된 좌표 → 실제 캔버스 좌표
|
||||
const s = scale;
|
||||
let x = (offset.x - rect.left) / s;
|
||||
let y = (offset.y - rect.top) / s;
|
||||
x -= item.component.width / 2;
|
||||
y -= item.component.height / 2;
|
||||
x = Math.max(0, Math.min(x, widthPx - item.component.width));
|
||||
|
|
@ -48,36 +82,56 @@ export function BarcodeDesignerCanvas() {
|
|||
addComponent(newComp);
|
||||
},
|
||||
collect: (m) => ({ isOver: m.isOver() }),
|
||||
}), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]);
|
||||
}), [widthPx, heightPx, scale, components.length, addComponent, snapValueToGrid]);
|
||||
|
||||
// 스케일된 캔버스가 컨테이너 안에 들어가도록 fit (하단 잘림 방지)
|
||||
const scaledW = widthPx * scale;
|
||||
const scaledH = heightPx * scale;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto bg-gray-100 p-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-gray-100 p-6"
|
||||
>
|
||||
{/* 래퍼: 스케일된 크기만큼 차지해서 flex로 정확히 가운데 + 하단 잘림 방지 */}
|
||||
<div
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(canvasRef as any).current = r;
|
||||
drop(r);
|
||||
}}
|
||||
className="relative bg-white shadow-lg"
|
||||
style={{
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
minWidth: widthPx,
|
||||
minHeight: heightPx,
|
||||
backgroundImage: showGrid
|
||||
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
||||
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) selectComponent(null);
|
||||
}}
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ width: scaledW, height: scaledH }}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(canvasRef as { current: HTMLDivElement | null }).current = r;
|
||||
drop(r);
|
||||
}}
|
||||
className="relative bg-white shadow-lg"
|
||||
style={{
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
minWidth: widthPx,
|
||||
minHeight: heightPx,
|
||||
backgroundImage: showGrid
|
||||
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
||||
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) selectComponent(null);
|
||||
}}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
|
|||
|
||||
export function BarcodeDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="flex min-h-0 w-64 shrink-0 flex-col overflow-hidden border-r bg-white">
|
||||
<div className="flex min-h-0 w-72 shrink-0 flex-col overflow-hidden border-r bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
|
|
|
|||
|
|
@ -114,11 +114,13 @@ export function BarcodeTemplatePalette() {
|
|||
key={t.template_id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto w-full justify-start py-1.5 text-left"
|
||||
className="h-auto w-full justify-start px-2 py-1.5 text-left"
|
||||
onClick={() => applyTemplate(t.template_id)}
|
||||
>
|
||||
<span className="truncate">{t.template_name_kor}</span>
|
||||
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
|
||||
<span className="block break-words text-left text-xs leading-tight">
|
||||
{t.template_name_kor}
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-0.5 block text-[10px]">
|
||||
{t.width_mm}×{t.height_mm}
|
||||
</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -184,7 +183,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
카메라로 바코드를 스캔하세요.
|
||||
카메라로 바코드를 스캔합니다.
|
||||
{targetField && ` (대상 필드: ${targetField})`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch, Bot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuItem } from "@/types/menu";
|
||||
import { MENU_ICONS, MESSAGES } from "@/constants/layout";
|
||||
|
|
@ -38,6 +38,9 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
|
|||
if (MENU_ICONS.DATAFLOW.some((keyword) => menuName.includes(keyword))) {
|
||||
return <GitBranch className="h-4 w-4" />;
|
||||
}
|
||||
if (MENU_ICONS.AI.some((keyword) => menuName.includes(keyword))) {
|
||||
return <Bot className="h-4 w-4" />;
|
||||
}
|
||||
return <FileText className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -471,7 +471,15 @@ export function PopCategoryTree({
|
|||
// 상태 관리
|
||||
const [groups, setGroups] = useState<PopScreenGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const saved = sessionStorage.getItem("pop-tree-expanded-groups");
|
||||
return saved ? new Set(JSON.parse(saved) as number[]) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||
|
||||
// 그룹 모달 상태
|
||||
|
|
@ -500,7 +508,15 @@ export function PopCategoryTree({
|
|||
const [moveSearchTerm, setMoveSearchTerm] = useState("");
|
||||
|
||||
// 미분류 회사코드별 접기/펼치기
|
||||
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set());
|
||||
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const saved = sessionStorage.getItem("pop-tree-expanded-companies");
|
||||
return saved ? new Set(JSON.parse(saved) as string[]) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 화면 맵 생성 (screen_id로 빠르게 조회)
|
||||
const screensMap = useMemo(() => {
|
||||
|
|
@ -544,6 +560,9 @@ export function PopCategoryTree({
|
|||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem("pop-tree-expanded-groups", JSON.stringify([...next]));
|
||||
} catch { /* noop */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
|
@ -1013,6 +1032,9 @@ export function PopCategoryTree({
|
|||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem("pop-tree-expanded-companies", JSON.stringify([...next]));
|
||||
} catch { /* noop */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const badgeVariants = cva(
|
|||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-500 text-white hover:bg-green-600",
|
||||
warning: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function SelectTrigger({
|
|||
size?: "xs" | "sm" | "default";
|
||||
}) {
|
||||
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
||||
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
|
||||
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || /\bh-\d/.test(className ?? "") || !!style?.height;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const MENU_ICONS = {
|
|||
STATISTICS: ["통계", "분석", "리포트", "차트"],
|
||||
SETTINGS: ["설정", "관리", "시스템"],
|
||||
DATAFLOW: ["데이터", "흐름", "관계", "연결"],
|
||||
AI: ["AI", "어시스턴트", "챗봇", "LLM"],
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
# AI 어시스턴트 메뉴 등록 가이드 (VEXPLOR)
|
||||
|
||||
AI 어시스턴트는 **VEXPLOR와 같은 서비스/같은 포트**로 동작합니다.
|
||||
프론트는 `/api/ai/v1` 로 호출하고, backend-node가 AI 서비스(기본 3100 포트)로 프록시합니다.
|
||||
|
||||
## 서비스 기동
|
||||
|
||||
- **AI API**: `ERP-node/ai-assistant` 에서 `npm install` 후 `npm start` (포트 3100)
|
||||
- **backend-node**: `npm run dev` (8080)
|
||||
- **frontend**: `npm run dev` (9771)
|
||||
|
||||
별도 포트/도메인 설정 없이 브라우저에서는 **localhost:9771** 만 사용하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## VEXPLOR 메뉴 URL 목록 (전체 탑재)
|
||||
|
||||
대메뉴 예: **AI 서비스** / **AI**
|
||||
소메뉴는 아래 표의 **메뉴명**과 **URL**로 등록하면 됩니다. (메뉴명에 "AI", "어시스턴트", "챗봇", "LLM" 포함 시 사이드바에 Bot 아이콘 표시)
|
||||
|
||||
### 일반 메뉴
|
||||
|
||||
| 메뉴명 | URL (메뉴 관리에 입력할 값) |
|
||||
|-------------|-------------------------------|
|
||||
| AI 채팅 | /admin/aiAssistant/chat |
|
||||
| 대시보드 | /admin/aiAssistant/dashboard |
|
||||
| API 키 관리 | /admin/aiAssistant/api-keys |
|
||||
| API 테스트 | /admin/aiAssistant/api-test |
|
||||
| 내 사용량 | /admin/aiAssistant/usage |
|
||||
| 대화 이력 | /admin/aiAssistant/history |
|
||||
| 설정 | /admin/aiAssistant/settings |
|
||||
|
||||
### 관리자 메뉴
|
||||
|
||||
| 메뉴명 | URL (메뉴 관리에 입력할 값) |
|
||||
|------------------|------------------------------------|
|
||||
| 사용자 관리 | /admin/aiAssistant/admin/users |
|
||||
| LLM 관리 | /admin/aiAssistant/admin/providers |
|
||||
| LLM 사용량 통계 | /admin/aiAssistant/admin/usage-stats |
|
||||
|
||||
---
|
||||
|
||||
## 등록 순서 예시
|
||||
|
||||
1. **대메뉴**: 메뉴명 `AI 서비스`, URL은 비우거나 `#` (자식만 사용할 경우)
|
||||
2. **소메뉴**: 위 표에서 필요한 것만 추가
|
||||
- 예: 메뉴명 `대시보드`, URL `/admin/aiAssistant/dashboard`
|
||||
- 예: 메뉴명 `API 키 관리`, URL `/admin/aiAssistant/api-keys`
|
||||
|
||||
이렇게 등록하면 VEXPLOR 사이드바에서 각 메뉴 클릭 시 해당 AI 어시스턴트 화면이 열립니다.
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
* - 향후 pop-table 행 액션 등
|
||||
*/
|
||||
|
||||
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
|
||||
import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
|
||||
|
|
@ -197,3 +197,156 @@ export async function executePopAction(
|
|||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// v2: 작업 목록 실행
|
||||
// ========================================
|
||||
|
||||
/** 수집된 데이터 구조 */
|
||||
export interface CollectedPayload {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
mappings?: {
|
||||
cardList?: Record<string, unknown> | null;
|
||||
field?: Record<string, unknown> | null;
|
||||
};
|
||||
cartChanges?: {
|
||||
toCreate?: Record<string, unknown>[];
|
||||
toUpdate?: Record<string, unknown>[];
|
||||
toDelete?: (string | number)[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 작업 목록 실행 옵션 */
|
||||
interface ExecuteTaskListOptions {
|
||||
publish: PublishFn;
|
||||
componentId: string;
|
||||
collectedData?: CollectedPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 목록을 순차 실행한다.
|
||||
* 데이터 관련 작업(data-save, data-update, data-delete, cart-save)은
|
||||
* 하나의 API 호출로 묶어 백엔드에서 트랜잭션 처리한다.
|
||||
* 나머지 작업(modal-open, navigate 등)은 프론트엔드에서 직접 처리한다.
|
||||
*/
|
||||
export async function executeTaskList(
|
||||
tasks: ButtonTask[],
|
||||
options: ExecuteTaskListOptions,
|
||||
): Promise<ActionResult> {
|
||||
const { publish, componentId, collectedData } = options;
|
||||
|
||||
// 데이터 작업과 프론트 전용 작업 분리
|
||||
const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]);
|
||||
const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type));
|
||||
const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type));
|
||||
|
||||
let backendData: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
// 1. 데이터 작업이 있으면 백엔드에 일괄 전송
|
||||
if (dataTasks.length > 0) {
|
||||
const result = await apiClient.post("/pop/execute-action", {
|
||||
tasks: dataTasks,
|
||||
data: {
|
||||
items: collectedData?.items ?? [],
|
||||
fieldValues: collectedData?.fieldValues ?? {},
|
||||
},
|
||||
mappings: collectedData?.mappings ?? {},
|
||||
cartChanges: collectedData?.cartChanges,
|
||||
});
|
||||
|
||||
if (!result.data?.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.data?.message || "데이터 작업 실행에 실패했습니다.",
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
backendData = result.data;
|
||||
}
|
||||
|
||||
const innerData = (backendData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
|
||||
const generatedCodes = innerData?.generatedCodes as
|
||||
Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined;
|
||||
const hasResultModal = generatedCodes?.some((g) => g.showResultModal);
|
||||
|
||||
// 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류)
|
||||
const deferredNavigateTasks: ButtonTask[] = [];
|
||||
for (const task of frontTasks) {
|
||||
switch (task.type) {
|
||||
case "modal-open":
|
||||
publish("__pop_modal_open__", {
|
||||
modalId: task.modalScreenId,
|
||||
title: task.modalTitle,
|
||||
mode: task.modalMode,
|
||||
items: task.modalItems,
|
||||
});
|
||||
break;
|
||||
|
||||
case "navigate":
|
||||
if (hasResultModal) {
|
||||
deferredNavigateTasks.push(task);
|
||||
} else if (task.targetScreenId) {
|
||||
publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params });
|
||||
}
|
||||
break;
|
||||
|
||||
case "close-modal":
|
||||
publish("__pop_close_modal__");
|
||||
break;
|
||||
|
||||
case "refresh":
|
||||
if (!hasResultModal) {
|
||||
publish("__pop_refresh__");
|
||||
}
|
||||
break;
|
||||
|
||||
case "api-call": {
|
||||
if (!task.apiEndpoint) break;
|
||||
const method = (task.apiMethod || "POST").toUpperCase();
|
||||
switch (method) {
|
||||
case "GET":
|
||||
await apiClient.get(task.apiEndpoint);
|
||||
break;
|
||||
case "PUT":
|
||||
await apiClient.put(task.apiEndpoint);
|
||||
break;
|
||||
case "DELETE":
|
||||
await apiClient.delete(task.apiEndpoint);
|
||||
break;
|
||||
default:
|
||||
await apiClient.post(task.apiEndpoint);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "custom-event":
|
||||
if (task.eventName) {
|
||||
publish(task.eventName, task.eventPayload ?? {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 완료 이벤트
|
||||
if (!hasResultModal) {
|
||||
publish(`__comp_output__${componentId}__action_completed`, {
|
||||
action: "task-list",
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
generatedCodes,
|
||||
deferredTasks: deferredNavigateTasks,
|
||||
...(backendData ?? {}),
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,5 +26,8 @@ export { useConnectionResolver } from "./useConnectionResolver";
|
|||
export { useCartSync } from "./useCartSync";
|
||||
export type { UseCartSyncReturn } from "./useCartSync";
|
||||
|
||||
// 설정 패널 접기/펼치기 상태 관리
|
||||
export { useCollapsibleSections } from "./useCollapsibleSections";
|
||||
|
||||
// SQL 빌더 유틸 (고급 사용 시)
|
||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ import type {
|
|||
|
||||
// ===== 반환 타입 =====
|
||||
|
||||
export interface CartChanges {
|
||||
toCreate: Record<string, unknown>[];
|
||||
toUpdate: Record<string, unknown>[];
|
||||
toDelete: (string | number)[];
|
||||
}
|
||||
|
||||
export interface UseCartSyncReturn {
|
||||
cartItems: CartItemWithId[];
|
||||
savedItems: CartItemWithId[];
|
||||
|
|
@ -48,6 +54,7 @@ export interface UseCartSyncReturn {
|
|||
isItemInCart: (rowKey: string) => boolean;
|
||||
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||
|
||||
getChanges: (selectedColumns?: string[]) => CartChanges;
|
||||
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||
loadFromDb: () => Promise<void>;
|
||||
resetToSaved: () => void;
|
||||
|
|
@ -252,6 +259,29 @@ export function useCartSync(
|
|||
[cartItems],
|
||||
);
|
||||
|
||||
// ----- diff 계산 (백엔드 전송용) -----
|
||||
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||
const toCreateItems = cartItems.filter((c) => !c.cartId);
|
||||
|
||||
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||
const toUpdateItems = cartItems.filter((c) => {
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
if (!saved) return false;
|
||||
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
|
||||
});
|
||||
|
||||
return {
|
||||
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
|
||||
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
|
||||
toDelete: toDeleteItems.map((item) => item.cartId!),
|
||||
};
|
||||
}, [cartItems, savedItems]);
|
||||
|
||||
// ----- DB 저장 (일괄) -----
|
||||
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
||||
setSyncStatus("saving");
|
||||
|
|
@ -324,6 +354,7 @@ export function useCartSync(
|
|||
updateItemQuantity,
|
||||
isItemInCart,
|
||||
getCartItem,
|
||||
getChanges,
|
||||
saveToDb,
|
||||
loadFromDb,
|
||||
resetToSaved,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* 설정 패널 접기/펼치기 상태를 sessionStorage로 기억하는 훅
|
||||
*
|
||||
* - 초기 상태: 모든 섹션 접힘
|
||||
* - 사용자가 펼친 섹션은 같은 탭 세션 내에서 기억
|
||||
* - 탭 닫으면 초기화
|
||||
*
|
||||
* @param storageKey sessionStorage 키 (예: "pop-card-list")
|
||||
*/
|
||||
export function useCollapsibleSections(storageKey: string) {
|
||||
const fullKey = `pop-config-sections-${storageKey}`;
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
try {
|
||||
const saved = sessionStorage.getItem(fullKey);
|
||||
if (saved) return new Set<string>(JSON.parse(saved));
|
||||
} catch {}
|
||||
return new Set<string>();
|
||||
});
|
||||
|
||||
const openSectionsRef = useRef(openSections);
|
||||
openSectionsRef.current = openSections;
|
||||
|
||||
const persist = useCallback(
|
||||
(next: Set<string>) => {
|
||||
try {
|
||||
sessionStorage.setItem(fullKey, JSON.stringify([...next]));
|
||||
} catch {}
|
||||
},
|
||||
[fullKey],
|
||||
);
|
||||
|
||||
const isOpen = useCallback(
|
||||
(key: string) => openSectionsRef.current.has(key),
|
||||
[],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(key: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
return { isOpen, toggle };
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* AI 어시스턴트 전용 API 클라이언트
|
||||
* - VEXPLOR와 같은 서비스/같은 포트: /api/ai/v1 로 호출 (Next → backend-node → AI 서비스 프록시)
|
||||
* - 인증 토큰은 sessionStorage 'ai-assistant-auth' 사용 (VEXPLOR 인증과 분리)
|
||||
*/
|
||||
import axios, { AxiosError } from "axios";
|
||||
import type { AiAssistantAuthState } from "./types";
|
||||
|
||||
const STORAGE_KEY = "ai-assistant-auth";
|
||||
|
||||
/** 같은 오리진 기준 AI API prefix (backend-node가 /api/ai/v1 을 AI 서비스로 프록시) */
|
||||
function getBaseUrl(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return "/api/ai/v1";
|
||||
}
|
||||
|
||||
export function getAiAssistantAuth(): AiAssistantAuthState | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as AiAssistantAuthState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAiAssistantAuth(state: AiAssistantAuthState | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getAiAssistantAccessToken(): string | null {
|
||||
return getAiAssistantAuth()?.accessToken ?? null;
|
||||
}
|
||||
|
||||
let refreshing = false;
|
||||
const queue: Array<{ resolve: (t: string) => void; reject: (e: unknown) => void }> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
|
||||
queue.length = 0;
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const base = getBaseUrl();
|
||||
if (base) config.baseURL = base;
|
||||
const token = getAiAssistantAccessToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: AxiosError) => {
|
||||
const original = err.config as typeof err.config & { _retry?: boolean };
|
||||
const isAuth = original?.url?.includes("/auth/");
|
||||
if (isAuth || err.response?.status !== 401 || original?._retry) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (refreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
queue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
if (original.headers) original.headers.Authorization = `Bearer ${token}`;
|
||||
return client(original);
|
||||
})
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
refreshing = true;
|
||||
const auth = getAiAssistantAuth();
|
||||
try {
|
||||
if (!auth?.refreshToken) throw new Error("No refresh token");
|
||||
const base = getBaseUrl();
|
||||
const { data } = await axios.post<{ data: { accessToken: string } }>(
|
||||
`${base}/auth/refresh`,
|
||||
{ refreshToken: auth.refreshToken },
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const newToken = data?.data?.accessToken;
|
||||
if (!newToken) throw new Error("No access token");
|
||||
const newState: AiAssistantAuthState = {
|
||||
...auth,
|
||||
accessToken: newToken,
|
||||
};
|
||||
setAiAssistantAuth(newState);
|
||||
processQueue(null, newToken);
|
||||
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`;
|
||||
return client(original);
|
||||
} catch (e) {
|
||||
processQueue(e, null);
|
||||
setAiAssistantAuth(null);
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** AI 어시스턴트 로그인 (이메일/비밀번호) */
|
||||
export async function loginAiAssistant(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AiAssistantAuthState> {
|
||||
const base = getBaseUrl();
|
||||
const { data } = await axios.post<{
|
||||
data: { user: AiAssistantAuthState["user"]; accessToken: string; refreshToken: string };
|
||||
}>(`${base}/auth/login`, { email, password }, { headers: { "Content-Type": "application/json" }, withCredentials: true });
|
||||
const state: AiAssistantAuthState = {
|
||||
user: data.data.user,
|
||||
accessToken: data.data.accessToken,
|
||||
refreshToken: data.data.refreshToken,
|
||||
};
|
||||
setAiAssistantAuth(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export default client;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export { default as aiAssistantApi } from "./client";
|
||||
export {
|
||||
getAiAssistantAuth,
|
||||
setAiAssistantAuth,
|
||||
getAiAssistantAccessToken,
|
||||
loginAiAssistant,
|
||||
} from "./client";
|
||||
export * from "./types";
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* AI 어시스턴트 API 응답/요청 타입 (workspace_assistant 백엔드 연동)
|
||||
*/
|
||||
|
||||
export interface AiAssistantUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "user" | "admin";
|
||||
status?: string;
|
||||
plan?: string;
|
||||
monthlyTokenLimit?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantAuthState {
|
||||
user: AiAssistantUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
usage?: {
|
||||
today?: { tokens?: number; requests?: number };
|
||||
monthly?: { totalTokens?: number; totalCost?: number };
|
||||
};
|
||||
limit?: { monthly?: number };
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
status: string;
|
||||
usageCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UsageLogItem {
|
||||
id: number;
|
||||
success: boolean;
|
||||
providerName?: string;
|
||||
modelName?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
responseTimeMs?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users?: { total?: number; active?: number };
|
||||
apiKeys?: { total?: number };
|
||||
providers?: { active?: number };
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: number;
|
||||
displayName: string;
|
||||
modelName: string;
|
||||
apiKey?: string;
|
||||
priority: number;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
isActive: boolean;
|
||||
costPer1kInputTokens?: number;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -702,11 +702,13 @@ export function PopCardListComponent({
|
|||
}
|
||||
: null;
|
||||
|
||||
const cartChanges = cart.isDirty ? cart.getChanges() : undefined;
|
||||
|
||||
const response: CollectedDataResponse = {
|
||||
requestId: request?.requestId ?? "",
|
||||
componentId: componentId,
|
||||
componentType: "pop-card-list",
|
||||
data: { items: selectedItems },
|
||||
data: { items: selectedItems, cartChanges },
|
||||
mapping,
|
||||
};
|
||||
|
||||
|
|
@ -714,7 +716,7 @@ export function PopCardListComponent({
|
|||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
|
||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]);
|
||||
|
||||
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
||||
useEffect(() => {
|
||||
|
|
@ -728,14 +730,13 @@ export function PopCardListComponent({
|
|||
gap: `${scaled.gap}px`,
|
||||
...(isHorizontalMode
|
||||
? {
|
||||
gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`,
|
||||
gridTemplateRows: `repeat(${gridRows}, minmax(${scaled.cardHeight}px, auto))`,
|
||||
gridAutoFlow: "column",
|
||||
gridAutoColumns: `${scaled.cardWidth}px`,
|
||||
}
|
||||
: {
|
||||
// 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지
|
||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||
gridAutoRows: `${scaled.cardHeight}px`,
|
||||
gridAutoRows: `minmax(${scaled.cardHeight}px, auto)`,
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -998,16 +999,18 @@ function Card({
|
|||
return 999999;
|
||||
}, [limitCol, row]);
|
||||
|
||||
// 제한 컬럼이 있으면 최대값으로 자동 초기화
|
||||
// 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지)
|
||||
useEffect(() => {
|
||||
if (isCartListMode) return;
|
||||
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||
setInputValue(effectiveMax);
|
||||
}
|
||||
}, [effectiveMax, inputField?.enabled, limitCol]);
|
||||
}, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
|
||||
|
||||
const hasPackageEntries = packageEntries.length > 0;
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
height: `${scaled.cardHeight}px`,
|
||||
overflow: "hidden",
|
||||
minHeight: `${scaled.cardHeight}px`,
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
|
|
@ -1113,7 +1116,7 @@ function Card({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||
style={cardStyle}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
|
|
@ -1154,7 +1157,7 @@ function Card({
|
|||
)}
|
||||
|
||||
{/* 본문 영역 */}
|
||||
<div className="flex" style={bodyStyle}>
|
||||
<div className="flex flex-1 overflow-hidden" style={bodyStyle}>
|
||||
{/* 이미지 (왼쪽) */}
|
||||
{image?.enabled && (
|
||||
<div className="shrink-0">
|
||||
|
|
@ -1196,7 +1199,7 @@ function Card({
|
|||
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
|
||||
{(inputField?.enabled || cartAction || isCartListMode) && (
|
||||
<div
|
||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-start gap-2"
|
||||
style={{ minWidth: "100px" }}
|
||||
>
|
||||
{/* 수량 버튼 (입력 필드 ON일 때만) */}
|
||||
|
|
@ -1265,6 +1268,37 @@ function Card({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 포장 요약 바: 본문 아래에 표시 */}
|
||||
{hasPackageEntries && (
|
||||
<div className="border-t bg-emerald-50">
|
||||
{packageEntries.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between px-3 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
포장완료
|
||||
</span>
|
||||
<Package className="h-4 w-4 text-emerald-600" />
|
||||
<span
|
||||
className="font-medium text-emerald-700"
|
||||
style={{ fontSize: `${scaled.bodyTextSize}px` }}
|
||||
>
|
||||
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="font-bold text-emerald-700"
|
||||
style={{ fontSize: `${scaled.bodyTextSize}px` }}
|
||||
>
|
||||
= {entry.totalQuantity.toLocaleString()}{inputField?.unit || "EA"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputField?.enabled && (
|
||||
<NumberInputModal
|
||||
open={isModalOpen}
|
||||
|
|
@ -1304,7 +1338,7 @@ function FieldRow({
|
|||
|
||||
// 구조화된 수식 우선
|
||||
if (field.formulaLeft && field.formulaOperator) {
|
||||
const rightVal = field.formulaRightType === "input"
|
||||
const rightVal = (field.formulaRightType || "input") === "input"
|
||||
? (inputValue ?? 0)
|
||||
: Number(row[field.formulaRight || ""] ?? 0);
|
||||
const leftVal = Number(row[field.formulaLeft] ?? 0);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
|
||||
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
|
||||
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
|
||||
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -135,6 +136,7 @@ const COLOR_OPTIONS = [
|
|||
|
||||
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
|
||||
const sections = useCollapsibleSections("pop-card-list");
|
||||
|
||||
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
|
||||
|
||||
|
|
@ -184,6 +186,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
|||
onUpdate={updateConfig}
|
||||
currentMode={currentMode}
|
||||
currentColSpan={currentColSpan}
|
||||
sections={sections}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "template" && (
|
||||
|
|
@ -195,7 +198,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
|
||||
<CardTemplateTab config={cfg} onUpdate={updateConfig} sections={sections} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -205,16 +208,20 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
|||
|
||||
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
|
||||
|
||||
type SectionsApi = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
|
||||
|
||||
function BasicSettingsTab({
|
||||
config,
|
||||
onUpdate,
|
||||
currentMode,
|
||||
currentColSpan,
|
||||
sections,
|
||||
}: {
|
||||
config: PopCardListConfig;
|
||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
||||
currentMode?: GridMode;
|
||||
currentColSpan?: number;
|
||||
sections: SectionsApi;
|
||||
}) {
|
||||
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
|
|
@ -321,7 +328,7 @@ function BasicSettingsTab({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 장바구니 목록 모드 */}
|
||||
<CollapsibleSection title="장바구니 목록 모드" defaultOpen={isCartListMode}>
|
||||
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
|
||||
<CartListModeSection
|
||||
cartListMode={config.cartListMode}
|
||||
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
|
||||
|
|
@ -330,7 +337,7 @@ function BasicSettingsTab({
|
|||
|
||||
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
|
||||
{!isCartListMode && (
|
||||
<CollapsibleSection title="테이블 선택" defaultOpen>
|
||||
<CollapsibleSection sectionKey="basic-table" title="테이블 선택" sections={sections}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">데이터 테이블</Label>
|
||||
|
|
@ -365,7 +372,9 @@ function BasicSettingsTab({
|
|||
{/* 조인 설정 (장바구니 모드 시 숨김) */}
|
||||
{!isCartListMode && dataSource.tableName && (
|
||||
<CollapsibleSection
|
||||
sectionKey="basic-join"
|
||||
title="조인 설정"
|
||||
sections={sections}
|
||||
badge={
|
||||
dataSource.joins && dataSource.joins.length > 0
|
||||
? `${dataSource.joins.length}개`
|
||||
|
|
@ -383,7 +392,9 @@ function BasicSettingsTab({
|
|||
{/* 정렬 기준 (장바구니 모드 시 숨김) */}
|
||||
{!isCartListMode && dataSource.tableName && (
|
||||
<CollapsibleSection
|
||||
sectionKey="basic-sort"
|
||||
title="정렬 기준"
|
||||
sections={sections}
|
||||
badge={
|
||||
dataSource.sort
|
||||
? Array.isArray(dataSource.sort)
|
||||
|
|
@ -403,7 +414,9 @@ function BasicSettingsTab({
|
|||
{/* 필터 기준 (장바구니 모드 시 숨김) */}
|
||||
{!isCartListMode && dataSource.tableName && (
|
||||
<CollapsibleSection
|
||||
sectionKey="basic-filter"
|
||||
title="필터 기준"
|
||||
sections={sections}
|
||||
badge={
|
||||
dataSource.filters && dataSource.filters.length > 0
|
||||
? `${dataSource.filters.length}개`
|
||||
|
|
@ -421,7 +434,9 @@ function BasicSettingsTab({
|
|||
{/* 저장 매핑 (장바구니 모드일 때만) */}
|
||||
{isCartListMode && (
|
||||
<CollapsibleSection
|
||||
sectionKey="basic-save-mapping"
|
||||
title="저장 매핑"
|
||||
sections={sections}
|
||||
badge={
|
||||
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
|
||||
? `${config.saveMapping.mappings.length}개`
|
||||
|
|
@ -437,7 +452,7 @@ function BasicSettingsTab({
|
|||
)}
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
||||
<CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
|
||||
<div className="space-y-3">
|
||||
{modeLabel && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
|
||||
|
|
@ -526,9 +541,11 @@ function BasicSettingsTab({
|
|||
function CardTemplateTab({
|
||||
config,
|
||||
onUpdate,
|
||||
sections,
|
||||
}: {
|
||||
config: PopCardListConfig;
|
||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
||||
sections: SectionsApi;
|
||||
}) {
|
||||
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
||||
const template = config.cardTemplate || DEFAULT_TEMPLATE;
|
||||
|
|
@ -634,7 +651,7 @@ function CardTemplateTab({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 설정 */}
|
||||
<CollapsibleSection title="헤더 설정" defaultOpen>
|
||||
<CollapsibleSection sectionKey="tpl-header" title="헤더 설정" sections={sections}>
|
||||
<HeaderSettingsSection
|
||||
header={template.header || DEFAULT_HEADER}
|
||||
columnGroups={columnGroups}
|
||||
|
|
@ -643,7 +660,7 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 이미지 설정 */}
|
||||
<CollapsibleSection title="이미지 설정" defaultOpen>
|
||||
<CollapsibleSection sectionKey="tpl-image" title="이미지 설정" sections={sections}>
|
||||
<ImageSettingsSection
|
||||
image={template.image || DEFAULT_IMAGE}
|
||||
columnGroups={columnGroups}
|
||||
|
|
@ -653,9 +670,10 @@ function CardTemplateTab({
|
|||
|
||||
{/* 본문 필드 */}
|
||||
<CollapsibleSection
|
||||
sectionKey="tpl-body"
|
||||
title="본문 필드"
|
||||
sections={sections}
|
||||
badge={`${template.body?.fields?.length || 0}개`}
|
||||
defaultOpen
|
||||
>
|
||||
<BodyFieldsSection
|
||||
body={template.body || DEFAULT_BODY}
|
||||
|
|
@ -665,7 +683,7 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 입력 필드 설정 */}
|
||||
<CollapsibleSection title="입력 필드" defaultOpen={false}>
|
||||
<CollapsibleSection sectionKey="tpl-input" title="입력 필드" sections={sections}>
|
||||
<InputFieldSettingsSection
|
||||
inputField={config.inputField}
|
||||
columns={columns}
|
||||
|
|
@ -675,7 +693,7 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 포장등록 설정 */}
|
||||
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}>
|
||||
<CollapsibleSection sectionKey="tpl-package" title="포장등록 (계산기)" sections={sections}>
|
||||
<PackageSettingsSection
|
||||
packageConfig={config.packageConfig}
|
||||
onUpdate={(packageConfig) => onUpdate({ packageConfig })}
|
||||
|
|
@ -683,7 +701,7 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 담기 버튼 설정 */}
|
||||
<CollapsibleSection title="담기 버튼" defaultOpen={false}>
|
||||
<CollapsibleSection sectionKey="tpl-cart" title="담기 버튼" sections={sections}>
|
||||
<CartActionSettingsSection
|
||||
cartAction={config.cartAction}
|
||||
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
||||
|
|
@ -693,7 +711,7 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 반응형 표시 설정 */}
|
||||
<CollapsibleSection title="반응형 표시" defaultOpen={false}>
|
||||
<CollapsibleSection sectionKey="tpl-responsive" title="반응형 표시" sections={sections}>
|
||||
<ResponsiveDisplaySection
|
||||
config={config}
|
||||
onUpdate={onUpdate}
|
||||
|
|
@ -769,24 +787,26 @@ function GroupedColumnSelect({
|
|||
// ===== 접기/펴기 섹션 컴포넌트 =====
|
||||
|
||||
function CollapsibleSection({
|
||||
sectionKey,
|
||||
title,
|
||||
badge,
|
||||
defaultOpen = false,
|
||||
sections,
|
||||
children,
|
||||
}: {
|
||||
sectionKey: string;
|
||||
title: string;
|
||||
badge?: string;
|
||||
defaultOpen?: boolean;
|
||||
sections: SectionsApi;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const open = sections.isOpen(sectionKey);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => sections.toggle(sectionKey)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{open ? (
|
||||
|
|
@ -2784,6 +2804,13 @@ function SaveMappingSection({
|
|||
label: f.label || f.columnName,
|
||||
badge: "본문",
|
||||
});
|
||||
} else if (f.valueType === "formula" && f.label) {
|
||||
const formulaKey = `__formula_${f.id || f.label}`;
|
||||
displayed.push({
|
||||
sourceField: formulaKey,
|
||||
label: f.label,
|
||||
badge: "수식",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (inputFieldConfig?.enabled) {
|
||||
|
|
@ -2855,6 +2882,21 @@ function SaveMappingSection({
|
|||
[mapping.mappings]
|
||||
);
|
||||
|
||||
// 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로)
|
||||
useEffect(() => {
|
||||
if (!mapping.targetTable || cardDisplayedFields.length === 0) return;
|
||||
const existing = new Set(mapping.mappings.map((m) => m.sourceField));
|
||||
const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField));
|
||||
if (missing.length === 0) return;
|
||||
onUpdate({
|
||||
...mapping,
|
||||
mappings: [
|
||||
...mapping.mappings,
|
||||
...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })),
|
||||
],
|
||||
});
|
||||
}, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 카드에 표시된 필드 중 아직 매핑되지 않은 것
|
||||
const unmappedCardFields = useMemo(
|
||||
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
|
||||
|
|
@ -2937,7 +2979,7 @@ function SaveMappingSection({
|
|||
</div>
|
||||
{isCartMeta(entry.sourceField) ? (
|
||||
!badge && <span className="text-[9px] text-muted-foreground">장바구니</span>
|
||||
) : (
|
||||
) : entry.sourceField.startsWith("__formula_") ? null : (
|
||||
<span className="truncate text-[9px] text-muted-foreground">
|
||||
{entry.sourceField}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1937,7 +1937,7 @@ function PageEditor({
|
|||
isPreviewing?: boolean;
|
||||
onUpdateItem?: (updatedItem: DashboardItem) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-2">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
FieldSectionStyle,
|
||||
PopFieldReadSource,
|
||||
PopFieldAutoGenMapping,
|
||||
SelectLinkedFilter,
|
||||
} from "./types";
|
||||
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
||||
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
||||
|
|
@ -60,6 +61,16 @@ export function PopFieldComponent({
|
|||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
||||
|
||||
const fieldIdToName = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const section of cfg.sections) {
|
||||
for (const f of section.fields ?? []) {
|
||||
map[f.id] = f.fieldName || f.id;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [cfg.sections]);
|
||||
|
||||
// ResizeObserver로 컨테이너 너비 감시
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
|
|
@ -211,6 +222,23 @@ export function PopFieldComponent({
|
|||
columnMapping: Object.fromEntries(
|
||||
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
||||
),
|
||||
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
||||
.filter((m) => m.numberingRuleId)
|
||||
.map((m) => ({
|
||||
numberingRuleId: m.numberingRuleId!,
|
||||
targetColumn: m.targetColumn,
|
||||
showResultModal: m.showResultModal,
|
||||
})),
|
||||
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
||||
.filter((m) => m.targetColumn)
|
||||
.map((m) => ({
|
||||
valueSource: m.valueSource,
|
||||
targetColumn: m.targetColumn,
|
||||
staticValue: m.staticValue,
|
||||
sourceJsonColumn: m.sourceJsonColumn,
|
||||
sourceJsonKey: m.sourceJsonKey,
|
||||
sourceDbColumn: m.sourceDbColumn,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
|
@ -360,6 +388,8 @@ export function PopFieldComponent({
|
|||
error={errors[fKey]}
|
||||
onChange={handleFieldChange}
|
||||
sectionStyle={section.style}
|
||||
allValues={allValues}
|
||||
fieldIdToName={fieldIdToName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -394,6 +424,8 @@ interface FieldRendererProps {
|
|||
error?: string;
|
||||
onChange: (fieldName: string, value: unknown) => void;
|
||||
sectionStyle: FieldSectionStyle;
|
||||
allValues?: Record<string, unknown>;
|
||||
fieldIdToName?: Record<string, string>;
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
|
|
@ -403,6 +435,8 @@ function FieldRenderer({
|
|||
error,
|
||||
onChange,
|
||||
sectionStyle,
|
||||
allValues,
|
||||
fieldIdToName,
|
||||
}: FieldRendererProps) {
|
||||
const handleChange = useCallback(
|
||||
(v: unknown) => onChange(field.fieldName, v),
|
||||
|
|
@ -429,7 +463,7 @@ function FieldRenderer({
|
|||
)}
|
||||
</label>
|
||||
)}
|
||||
{renderByType(field, value, handleChange, inputClassName)}
|
||||
{renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
|
||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -443,7 +477,9 @@ function renderByType(
|
|||
field: PopFieldItem,
|
||||
value: unknown,
|
||||
onChange: (v: unknown) => void,
|
||||
className: string
|
||||
className: string,
|
||||
allValues?: Record<string, unknown>,
|
||||
fieldIdToName?: Record<string, string>,
|
||||
) {
|
||||
switch (field.inputType) {
|
||||
case "text":
|
||||
|
|
@ -482,6 +518,8 @@ function renderByType(
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
allValues={allValues}
|
||||
fieldIdToName={fieldIdToName}
|
||||
/>
|
||||
);
|
||||
case "auto":
|
||||
|
|
@ -554,11 +592,15 @@ function SelectFieldInput({
|
|||
value,
|
||||
onChange,
|
||||
className,
|
||||
allValues,
|
||||
fieldIdToName,
|
||||
}: {
|
||||
field: PopFieldItem;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
className: string;
|
||||
allValues?: Record<string, unknown>;
|
||||
fieldIdToName?: Record<string, string>;
|
||||
}) {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||
[]
|
||||
|
|
@ -566,6 +608,30 @@ function SelectFieldInput({
|
|||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const source = field.selectSource;
|
||||
const linkedFilters = source?.linkedFilters;
|
||||
const hasLinkedFilters = !!linkedFilters?.length;
|
||||
|
||||
// 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화
|
||||
const linkedFilterKey = useMemo(() => {
|
||||
if (!hasLinkedFilters || !allValues || !fieldIdToName) return "";
|
||||
return linkedFilters!
|
||||
.map((lf) => {
|
||||
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||
const val = allValues[fieldName] ?? "";
|
||||
return `${lf.filterColumn}=${String(val)}`;
|
||||
})
|
||||
.join("&");
|
||||
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||
|
||||
// 연동 필터의 소스 값이 모두 채워졌는지 확인
|
||||
const linkedFiltersFilled = useMemo(() => {
|
||||
if (!hasLinkedFilters || !allValues || !fieldIdToName) return true;
|
||||
return linkedFilters!.every((lf) => {
|
||||
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||
const val = allValues[fieldName];
|
||||
return val != null && val !== "";
|
||||
});
|
||||
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
|
|
@ -581,22 +647,44 @@ function SelectFieldInput({
|
|||
source.valueColumn &&
|
||||
source.labelColumn
|
||||
) {
|
||||
// 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시
|
||||
if (hasLinkedFilters && !linkedFiltersFilled) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동적 필터 구성
|
||||
const dynamicFilters: Record<string, string> = {};
|
||||
if (hasLinkedFilters && allValues && fieldIdToName) {
|
||||
for (const lf of linkedFilters!) {
|
||||
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||
const val = allValues[fieldName];
|
||||
if (val != null && val !== "" && lf.filterColumn) {
|
||||
dynamicFilters[lf.filterColumn] = String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dataApi
|
||||
.getTableData(source.tableName, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortColumn: source.labelColumn,
|
||||
sortDirection: "asc",
|
||||
size: 500,
|
||||
sortBy: source.labelColumn,
|
||||
sortOrder: "asc",
|
||||
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data?.success && Array.isArray(res.data.data?.data)) {
|
||||
setOptions(
|
||||
res.data.data.data.map((row: Record<string, unknown>) => ({
|
||||
value: String(row[source.valueColumn!] ?? ""),
|
||||
label: String(row[source.labelColumn!] ?? ""),
|
||||
}))
|
||||
);
|
||||
if (Array.isArray(res.data)) {
|
||||
const seen = new Set<string>();
|
||||
const deduped: { value: string; label: string }[] = [];
|
||||
for (const row of res.data) {
|
||||
const v = String(row[source.valueColumn!] ?? "");
|
||||
if (!v || seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") });
|
||||
}
|
||||
setOptions(deduped);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -604,7 +692,16 @@ function SelectFieldInput({
|
|||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]);
|
||||
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]);
|
||||
|
||||
// W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화
|
||||
useEffect(() => {
|
||||
if (!hasLinkedFilters || !value || loading) return;
|
||||
const currentStr = String(value);
|
||||
if (options.length > 0 && !options.some((o) => o.value === currentStr)) {
|
||||
onChange("");
|
||||
}
|
||||
}, [options, hasLinkedFilters]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -631,6 +728,11 @@ function SelectFieldInput({
|
|||
);
|
||||
}
|
||||
|
||||
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
|
||||
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
|
||||
? "상위 필드를 먼저 선택하세요"
|
||||
: "옵션이 없습니다";
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(value ?? "")}
|
||||
|
|
@ -642,7 +744,7 @@ function SelectFieldInput({
|
|||
<SelectContent>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
옵션이 없습니다
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
options.map((opt) => (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -52,6 +53,7 @@ import type {
|
|||
FieldSectionStyle,
|
||||
FieldSectionAppearance,
|
||||
FieldSelectSource,
|
||||
SelectLinkedFilter,
|
||||
AutoNumberConfig,
|
||||
FieldValueSource,
|
||||
PopFieldSaveMapping,
|
||||
|
|
@ -74,7 +76,7 @@ import {
|
|||
type ColumnInfo,
|
||||
} from "../pop-dashboard/utils/dataFetcher";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -213,6 +215,7 @@ export function PopFieldConfigPanel({
|
|||
onUpdate={(partial) => updateSection(section.id, partial)}
|
||||
onRemove={() => removeSection(section.id)}
|
||||
onMoveUp={() => moveSectionUp(idx)}
|
||||
allSections={cfg.sections}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
|
|
@ -462,6 +465,8 @@ function SaveTabContent({
|
|||
// --- 자동생성 필드 로직 ---
|
||||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
|
||||
const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]);
|
||||
const [showAllRules, setShowAllRules] = useState(false);
|
||||
|
||||
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화
|
||||
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
|
||||
|
|
@ -478,7 +483,7 @@ function SaveTabContent({
|
|||
label: field.labelText || "",
|
||||
targetColumn: "",
|
||||
numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
|
||||
showInForm: true,
|
||||
showInForm: false,
|
||||
showResultModal: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -513,6 +518,24 @@ function SaveTabContent({
|
|||
}
|
||||
}, [saveTableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAllRules) return;
|
||||
if (allNumberingRules.length > 0) return;
|
||||
getNumberingRules()
|
||||
.then((res) => {
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setAllNumberingRules(
|
||||
res.data.map((r: any) => ({
|
||||
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
|
||||
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
|
||||
tableName: String(r.tableName ?? r.table_name ?? ""),
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => setAllNumberingRules([]));
|
||||
}, [showAllRules, allNumberingRules.length]);
|
||||
|
||||
const addAutoGenMapping = useCallback(() => {
|
||||
const newMapping: PopFieldAutoGenMapping = {
|
||||
id: `autogen_${Date.now()}`,
|
||||
|
|
@ -626,10 +649,7 @@ function SaveTabContent({
|
|||
|
||||
const noFields = allFields.length === 0;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const toggleSection = useCallback((key: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
const sections = useCollapsibleSections("pop-field");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -644,16 +664,16 @@ function SaveTabContent({
|
|||
<div className="rounded-md border bg-card">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||
onClick={() => toggleSection("table")}
|
||||
onClick={() => sections.toggle("table")}
|
||||
>
|
||||
{collapsed["table"] ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
{sections.isOpen("table") ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">테이블 설정</span>
|
||||
</div>
|
||||
{!collapsed["table"] && <div className="space-y-3 border-t p-3">
|
||||
{sections.isOpen("table") && <div className="space-y-3 border-t p-3">
|
||||
{/* 읽기 테이블 (display 섹션이 있을 때만) */}
|
||||
{hasDisplayFields && (
|
||||
<>
|
||||
|
|
@ -817,19 +837,19 @@ function SaveTabContent({
|
|||
<div className="rounded-md border bg-card">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||
onClick={() => toggleSection("read")}
|
||||
onClick={() => sections.toggle("read")}
|
||||
>
|
||||
{collapsed["read"] ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
{sections.isOpen("read") ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">읽기 필드</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
(읽기 폼)
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed["read"] && <div className="space-y-2 border-t p-3">
|
||||
{sections.isOpen("read") && <div className="space-y-2 border-t p-3">
|
||||
{readColumns.length === 0 ? (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
읽기 테이블의 컬럼을 불러오는 중...
|
||||
|
|
@ -966,19 +986,19 @@ function SaveTabContent({
|
|||
<div className="rounded-md border bg-card">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||
onClick={() => toggleSection("input")}
|
||||
onClick={() => sections.toggle("input")}
|
||||
>
|
||||
{collapsed["input"] ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
{sections.isOpen("input") ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">입력 필드</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
(입력 폼 → 저장)
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed["input"] && <div className="space-y-2 border-t p-3">
|
||||
{sections.isOpen("input") && <div className="space-y-2 border-t p-3">
|
||||
{saveColumns.length === 0 ? (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
저장 테이블의 컬럼을 불러오는 중...
|
||||
|
|
@ -1028,21 +1048,23 @@ function SaveTabContent({
|
|||
<div className="rounded-md border bg-card">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||
onClick={() => toggleSection("hidden")}
|
||||
onClick={() => sections.toggle("hidden")}
|
||||
>
|
||||
{collapsed["hidden"] ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
{sections.isOpen("hidden") ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">숨은 필드</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
(UI 미표시, 전달 데이터에서 추출하여 저장)
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
|
||||
{sections.isOpen("hidden") && <div className="space-y-3 border-t p-3">
|
||||
{hiddenMappings.map((m) => {
|
||||
const isJson = m.valueSource === "json_extract";
|
||||
const isStatic = m.valueSource === "static";
|
||||
const isDbColumn = m.valueSource === "db_column";
|
||||
return (
|
||||
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -1070,6 +1092,7 @@ function SaveTabContent({
|
|||
sourceDbColumn: undefined,
|
||||
sourceJsonColumn: undefined,
|
||||
sourceJsonKey: undefined,
|
||||
staticValue: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
@ -1079,29 +1102,36 @@ function SaveTabContent({
|
|||
<SelectContent>
|
||||
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
||||
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
||||
<SelectItem value="static" className="text-xs">고정값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isJson && (
|
||||
<>
|
||||
<Select
|
||||
value={m.sourceDbColumn || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="소스 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs">선택</SelectItem>
|
||||
{readColumns.map((c) => (
|
||||
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
||||
{colName(c)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
{isDbColumn && (
|
||||
<Select
|
||||
value={m.sourceDbColumn || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="소스 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs">선택</SelectItem>
|
||||
{readColumns.map((c) => (
|
||||
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
||||
{colName(c)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isStatic && (
|
||||
<Input
|
||||
value={m.staticValue || ""}
|
||||
onChange={(e) => updateHiddenMapping(m.id, { staticValue: e.target.value })}
|
||||
placeholder="고정값 입력"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isJson && (
|
||||
|
|
@ -1183,19 +1213,19 @@ function SaveTabContent({
|
|||
<div className="rounded-md border bg-card">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||
onClick={() => toggleSection("autogen")}
|
||||
onClick={() => sections.toggle("autogen")}
|
||||
>
|
||||
{collapsed["autogen"] ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
{sections.isOpen("autogen") ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">자동생성 필드</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
(저장 시 서버에서 채번)
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3">
|
||||
{sections.isOpen("autogen") && <div className="space-y-3 border-t p-3">
|
||||
{autoGenMappings.map((m) => {
|
||||
const isLinked = !!m.linkedFieldId;
|
||||
return (
|
||||
|
|
@ -1248,7 +1278,19 @@ function SaveTabContent({
|
|||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">채번 규칙</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">채번 규칙</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
checked={showAllRules}
|
||||
onCheckedChange={setShowAllRules}
|
||||
className="h-3.5 w-7 data-[state=checked]:bg-primary [&>span]:h-2.5 [&>span]:w-2.5"
|
||||
/>
|
||||
<Label className="cursor-pointer text-[10px] text-muted-foreground" onClick={() => setShowAllRules(!showAllRules)}>
|
||||
전체 보기
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={m.numberingRuleId || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -1262,11 +1304,19 @@ function SaveTabContent({
|
|||
<SelectItem value="__none__" className="text-xs">
|
||||
선택
|
||||
</SelectItem>
|
||||
{numberingRules.map((r) => (
|
||||
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
||||
{r.ruleName || r.ruleId}
|
||||
</SelectItem>
|
||||
))}
|
||||
{showAllRules
|
||||
? allNumberingRules.map((r) => (
|
||||
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
||||
{r.ruleName || r.ruleId}
|
||||
<span className="ml-1 text-muted-foreground">({r.tableName || "-"})</span>
|
||||
</SelectItem>
|
||||
))
|
||||
: numberingRules.map((r) => (
|
||||
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
||||
{r.ruleName || r.ruleId}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -1325,6 +1375,7 @@ interface SectionEditorProps {
|
|||
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
allSections: PopFieldSection[];
|
||||
}
|
||||
|
||||
function migrateStyle(style: string): FieldSectionStyle {
|
||||
|
|
@ -1341,8 +1392,9 @@ function SectionEditor({
|
|||
onUpdate,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
allSections,
|
||||
}: SectionEditorProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const resolvedStyle = migrateStyle(section.style);
|
||||
|
||||
const sectionFields = section.fields || [];
|
||||
|
|
@ -1522,6 +1574,7 @@ function SectionEditor({
|
|||
sectionStyle={resolvedStyle}
|
||||
onUpdate={(partial) => updateField(field.id, partial)}
|
||||
onRemove={() => removeField(field.id)}
|
||||
allSections={allSections}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
|
|
@ -1549,6 +1602,7 @@ interface FieldItemEditorProps {
|
|||
sectionStyle?: FieldSectionStyle;
|
||||
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
||||
onRemove: () => void;
|
||||
allSections?: PopFieldSection[];
|
||||
}
|
||||
|
||||
function FieldItemEditor({
|
||||
|
|
@ -1556,6 +1610,7 @@ function FieldItemEditor({
|
|||
sectionStyle,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
allSections,
|
||||
}: FieldItemEditorProps) {
|
||||
const isDisplay = sectionStyle === "display";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
|
@ -1645,9 +1700,9 @@ function FieldItemEditor({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
|
||||
{/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */}
|
||||
{!isDisplay && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={field.readOnly || false}
|
||||
|
|
@ -1666,6 +1721,29 @@ function FieldItemEditor({
|
|||
/>
|
||||
<Label className="text-[10px]">필수</Label>
|
||||
</div>
|
||||
{field.inputType === "select" && field.selectSource?.type === "table" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={!!field.selectSource?.linkedFilters?.length}
|
||||
onCheckedChange={(v) => {
|
||||
const src = field.selectSource ?? { type: "table" as const };
|
||||
if (v) {
|
||||
onUpdate({
|
||||
selectSource: {
|
||||
...src,
|
||||
linkedFilters: [{ sourceFieldId: "", filterColumn: "" }],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onUpdate({
|
||||
selectSource: { ...src, linkedFilters: undefined },
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label className="text-[10px]">데이터 연동</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1690,12 +1768,31 @@ function FieldItemEditor({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* auto 전용: 채번 설정 */}
|
||||
{/* select + table + 연동 필터 활성화 시 */}
|
||||
{field.inputType === "select" &&
|
||||
field.selectSource?.type === "table" &&
|
||||
field.selectSource?.linkedFilters &&
|
||||
field.selectSource.linkedFilters.length > 0 && (
|
||||
<LinkedFiltersEditor
|
||||
linkedFilters={field.selectSource.linkedFilters}
|
||||
tableName={field.selectSource.tableName || ""}
|
||||
currentFieldId={field.id}
|
||||
allSections={allSections || []}
|
||||
onUpdate={(filters) =>
|
||||
onUpdate({
|
||||
selectSource: { ...field.selectSource!, linkedFilters: filters },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
|
||||
{field.inputType === "auto" && (
|
||||
<AutoNumberEditor
|
||||
config={field.autoNumber}
|
||||
onUpdate={(autoNumber) => onUpdate({ autoNumber })}
|
||||
/>
|
||||
<div className="rounded border bg-muted/30 p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
채번 규칙은 [저장] 탭 > 자동생성 필드에서 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1948,108 +2045,7 @@ function TableSourceEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// AutoNumberEditor: 자동 채번 설정
|
||||
// ========================================
|
||||
|
||||
function AutoNumberEditor({
|
||||
config,
|
||||
onUpdate,
|
||||
}: {
|
||||
config?: AutoNumberConfig;
|
||||
onUpdate: (config: AutoNumberConfig) => void;
|
||||
}) {
|
||||
const current: AutoNumberConfig = config || {
|
||||
prefix: "",
|
||||
dateFormat: "YYYYMMDD",
|
||||
separator: "-",
|
||||
sequenceDigits: 3,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
||||
<Label className="text-[10px] text-muted-foreground">자동 채번 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">접두사</Label>
|
||||
<Input
|
||||
value={current.prefix || ""}
|
||||
onChange={(e) => onUpdate({ ...current, prefix: e.target.value })}
|
||||
placeholder="IN-"
|
||||
className="mt-0.5 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">날짜 형식</Label>
|
||||
<Select
|
||||
value={current.dateFormat || "YYYYMMDD"}
|
||||
onValueChange={(v) => onUpdate({ ...current, dateFormat: v })}
|
||||
>
|
||||
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="YYYYMMDD" className="text-xs">
|
||||
YYYYMMDD
|
||||
</SelectItem>
|
||||
<SelectItem value="YYMMDD" className="text-xs">
|
||||
YYMMDD
|
||||
</SelectItem>
|
||||
<SelectItem value="YYMM" className="text-xs">
|
||||
YYMM
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">구분자</Label>
|
||||
<Input
|
||||
value={current.separator || ""}
|
||||
onChange={(e) => onUpdate({ ...current, separator: e.target.value })}
|
||||
placeholder="-"
|
||||
className="mt-0.5 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">시퀀스 자릿수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={current.sequenceDigits || 3}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...current,
|
||||
sequenceDigits: Number(e.target.value) || 3,
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={10}
|
||||
className="mt-0.5 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
미리보기:{" "}
|
||||
<span className="font-mono">
|
||||
{current.prefix || ""}
|
||||
{current.separator || ""}
|
||||
{current.dateFormat === "YYMM"
|
||||
? "2602"
|
||||
: current.dateFormat === "YYMMDD"
|
||||
? "260226"
|
||||
: "20260226"}
|
||||
{current.separator || ""}
|
||||
{"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리
|
||||
|
||||
// ========================================
|
||||
// JsonKeySelect: JSON 키 드롭다운 (자동 추출)
|
||||
|
|
@ -2132,6 +2128,118 @@ function JsonKeySelect({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LinkedFiltersEditor: 데이터 연동 필터 설정
|
||||
// ========================================
|
||||
|
||||
function LinkedFiltersEditor({
|
||||
linkedFilters,
|
||||
tableName,
|
||||
currentFieldId,
|
||||
allSections,
|
||||
onUpdate,
|
||||
}: {
|
||||
linkedFilters: SelectLinkedFilter[];
|
||||
tableName: string;
|
||||
currentFieldId: string;
|
||||
allSections: PopFieldSection[];
|
||||
onUpdate: (filters: SelectLinkedFilter[]) => void;
|
||||
}) {
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableName) {
|
||||
fetchTableColumns(tableName).then(setColumns);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [tableName]);
|
||||
|
||||
const candidateFields = useMemo(() => {
|
||||
return allSections.flatMap((sec) =>
|
||||
(sec.fields ?? [])
|
||||
.filter((f) => f.id !== currentFieldId)
|
||||
.map((f) => ({ id: f.id, label: f.labelText || f.fieldName || f.id, sectionLabel: sec.label }))
|
||||
);
|
||||
}, [allSections, currentFieldId]);
|
||||
|
||||
const updateFilter = (idx: number, partial: Partial<SelectLinkedFilter>) => {
|
||||
const next = linkedFilters.map((f, i) => (i === idx ? { ...f, ...partial } : f));
|
||||
onUpdate(next);
|
||||
};
|
||||
|
||||
const removeFilter = (idx: number) => {
|
||||
const next = linkedFilters.filter((_, i) => i !== idx);
|
||||
onUpdate(next.length > 0 ? next : [{ sourceFieldId: "", filterColumn: "" }]);
|
||||
};
|
||||
|
||||
const addFilter = () => {
|
||||
onUpdate([...linkedFilters, { sourceFieldId: "", filterColumn: "" }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
||||
<Label className="text-[10px] text-muted-foreground">데이터 연동</Label>
|
||||
{linkedFilters.map((lf, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={lf.sourceFieldId || "__none__"}
|
||||
onValueChange={(v) => updateFilter(idx, { sourceFieldId: v === "__none__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="연동 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs">필드 선택</SelectItem>
|
||||
{candidateFields.map((cf) => (
|
||||
<SelectItem key={cf.id} value={cf.id} className="text-xs">
|
||||
{cf.sectionLabel ? `[${cf.sectionLabel}] ` : ""}{cf.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">=</span>
|
||||
<Select
|
||||
value={lf.filterColumn || "__none__"}
|
||||
onValueChange={(v) => updateFilter(idx, { filterColumn: v === "__none__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="필터 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs">컬럼 선택</SelectItem>
|
||||
{columns.map((c) => (
|
||||
<SelectItem key={c.name} value={c.name} className="text-xs">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{linkedFilters.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-destructive"
|
||||
onClick={() => removeFilter(idx)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-full text-[10px]"
|
||||
onClick={addFilter}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// AppearanceEditor: 섹션 외관 설정
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSection
|
|||
|
||||
export type FieldSelectSourceType = "static" | "table";
|
||||
|
||||
export interface SelectLinkedFilter {
|
||||
sourceFieldId: string;
|
||||
filterColumn: string;
|
||||
}
|
||||
|
||||
export interface FieldSelectSource {
|
||||
type: FieldSelectSourceType;
|
||||
staticOptions?: { value: string; label: string }[];
|
||||
|
|
@ -51,6 +56,7 @@ export interface FieldSelectSource {
|
|||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
filters?: DataSourceFilter[];
|
||||
linkedFilters?: SelectLinkedFilter[];
|
||||
}
|
||||
|
||||
// ===== 자동 채번 설정 =====
|
||||
|
|
@ -124,7 +130,7 @@ export interface PopFieldSaveMapping {
|
|||
|
||||
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
||||
|
||||
export type HiddenValueSource = "json_extract" | "db_column";
|
||||
export type HiddenValueSource = "json_extract" | "db_column" | "static";
|
||||
|
||||
export interface PopFieldHiddenMapping {
|
||||
id: string;
|
||||
|
|
@ -133,6 +139,7 @@ export interface PopFieldHiddenMapping {
|
|||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
sourceDbColumn?: string;
|
||||
staticValue?: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -638,6 +638,19 @@ export interface CollectedDataResponse {
|
|||
export interface SaveMapping {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
autoGenMappings?: Array<{
|
||||
numberingRuleId: string;
|
||||
targetColumn: string;
|
||||
showResultModal?: boolean;
|
||||
}>;
|
||||
hiddenMappings?: Array<{
|
||||
valueSource: "json_extract" | "db_column" | "static";
|
||||
targetColumn: string;
|
||||
staticValue?: string;
|
||||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
sourceDbColumn?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StatusChangeRule {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,9 @@ const nextConfig = {
|
|||
},
|
||||
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
|
||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
||||
// Docker: SERVER_API_URL 사용. 로컬: 127.0.0.1 사용 (localhost는 IPv6 ::1 로 해석되어 ECONNREFUSED 나는 경우 있음)
|
||||
async rewrites() {
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
|
|
|
|||
Loading…
Reference in New Issue