475 lines
12 KiB
JavaScript
475 lines
12 KiB
JavaScript
// 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);
|
|
}
|
|
};
|