// 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); } };