ERP-node/ai-assistant/src/controllers/admin.controller.js

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