diff --git a/cli/index.js b/cli/index.js index 972ed0d7..78f6f959 100644 --- a/cli/index.js +++ b/cli/index.js @@ -7,6 +7,8 @@ const ora = require('ora'); const Table = require('table').table; const bcrypt = require('bcryptjs'); const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); const config = require('../config/config'); const redis = require('../src/models/redis'); @@ -104,6 +106,27 @@ program async function createInitialAdmin() { console.log(styles.title('\n🔐 创建初始管理员账户\n')); + // 检查是否已存在 init.json + const initFilePath = path.join(__dirname, '..', 'data', 'init.json'); + if (fs.existsSync(initFilePath)) { + const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); + console.log(styles.warning('⚠️ 检测到已存在管理员账户!')); + console.log(` 用户名: ${existingData.adminUsername}`); + console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`); + + const { overwrite } = await inquirer.prompt([{ + type: 'confirm', + name: 'overwrite', + message: '是否覆盖现有管理员账户?', + default: false + }]); + + if (!overwrite) { + console.log(styles.info('ℹ️ 已取消创建')); + return; + } + } + const adminData = await inquirer.prompt([ { type: 'input', @@ -129,20 +152,43 @@ async function createInitialAdmin() { const spinner = ora('正在创建管理员账户...').start(); try { + // 1. 先更新 init.json(唯一真实数据源) + const initData = { + initializedAt: new Date().toISOString(), + adminUsername: adminData.username, + adminPassword: adminData.password, // 保存明文密码 + version: '1.0.0', + updatedAt: new Date().toISOString() + }; + + // 确保 data 目录存在 + const dataDir = path.join(__dirname, '..', 'data'); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + // 写入文件 + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)); + + // 2. 再更新 Redis 缓存 const passwordHash = await bcrypt.hash(adminData.password, 12); const credentials = { username: adminData.username, passwordHash, createdAt: new Date().toISOString(), - id: crypto.randomBytes(16).toString('hex') + lastLogin: null, + updatedAt: new Date().toISOString() }; await redis.setSession('admin_credentials', credentials, 0); // 永不过期 spinner.succeed('管理员账户创建成功'); console.log(`${styles.success('✅')} 用户名: ${adminData.username}`); + console.log(`${styles.success('✅')} 密码: ${adminData.password}`); console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`); + console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`); + console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`); } catch (error) { spinner.fail('创建管理员账户失败'); diff --git a/scripts/setup.js b/scripts/setup.js index c5b9d2a3..56e6c789 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -92,7 +92,11 @@ function checkInitialized() { console.log(chalk.yellow('⚠️ 服务已经初始化过了!')); console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`); console.log(` 管理员用户名: ${initData.adminUsername}`); - console.log('\n如需重新初始化,请删除 data/init.json 文件。'); + console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。'); + console.log(chalk.red('\n⚠️ 重要提示:')); + console.log(' 1. 删除 init.json 文件后运行 npm run setup'); + console.log(' 2. 生成新的账号密码后,需要重启服务才能生效'); + console.log(' 3. 使用 npm run service:restart 重启服务\n'); return true; } return false; diff --git a/src/app.js b/src/app.js index 12b2a412..00b69c1b 100644 --- a/src/app.js +++ b/src/app.js @@ -183,40 +183,37 @@ class Application { } } - // 🔧 初始化管理员凭据 + // 🔧 初始化管理员凭据(总是从 init.json 加载,确保数据一致性) async initializeAdmin() { try { - // 检查Redis中是否已存在管理员凭据 - const existingAdmin = await redis.getSession('admin_credentials'); + const initFilePath = path.join(__dirname, '..', 'data', 'init.json'); - if (!existingAdmin || Object.keys(existingAdmin).length === 0) { - // 尝试从初始化文件读取 - const initFilePath = path.join(__dirname, '..', 'data', 'init.json'); - - if (fs.existsSync(initFilePath)) { - const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - - // 将明文密码哈希化 - const saltRounds = 10; - const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds); - - // 存储到Redis - const adminCredentials = { - username: initData.adminUsername, - passwordHash: passwordHash, - createdAt: new Date().toISOString(), - lastLogin: null - }; - - await redis.setSession('admin_credentials', adminCredentials); - - logger.success('✅ Admin credentials initialized from setup data'); - } else { - logger.warn('⚠️ No admin credentials found. Please run npm run setup first.'); - } - } else { - logger.info('ℹ️ Admin credentials already exist in Redis'); + if (!fs.existsSync(initFilePath)) { + logger.warn('⚠️ No admin credentials found. Please run npm run setup first.'); + return; } + + // 从 init.json 读取管理员凭据(作为唯一真实数据源) + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); + + // 将明文密码哈希化 + const saltRounds = 10; + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds); + + // 存储到Redis(每次启动都覆盖,确保与 init.json 同步) + const adminCredentials = { + username: initData.adminUsername, + passwordHash: passwordHash, + createdAt: initData.initializedAt || new Date().toISOString(), + lastLogin: null, + updatedAt: initData.updatedAt || null + }; + + await redis.setSession('admin_credentials', adminCredentials); + + logger.success('✅ Admin credentials loaded from init.json (single source of truth)'); + logger.info(`📋 Admin username: ${adminCredentials.username}`); + } catch (error) { logger.error('❌ Failed to initialize admin credentials:', { error: error.message, stack: error.stack }); throw error; diff --git a/src/routes/admin.js b/src/routes/admin.js index 58bcdbbc..24944ed6 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -552,26 +552,70 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { let dayCacheReadTokens = 0; let dayCost = 0; - for (const key of keys) { - const data = await client.hgetall(key); - if (data) { - dayInputTokens += parseInt(data.inputTokens) || 0; - dayOutputTokens += parseInt(data.outputTokens) || 0; - dayRequests += parseInt(data.requests) || 0; - dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; - dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0; + // 按模型统计使用量 + const modelUsageMap = new Map(); + + // 获取当天所有模型的使用数据 + const modelPattern = `usage:model:daily:*:${dateStr}`; + const modelKeys = await client.keys(modelPattern); + + for (const modelKey of modelKeys) { + // 解析模型名称 + const modelMatch = modelKey.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(modelKey); + + if (data && Object.keys(data).length > 0) { + const modelInputTokens = parseInt(data.inputTokens) || 0; + const modelOutputTokens = parseInt(data.outputTokens) || 0; + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const modelRequests = parseInt(data.requests) || 0; + + // 累加总数 + dayInputTokens += modelInputTokens; + dayOutputTokens += modelOutputTokens; + dayCacheCreateTokens += modelCacheCreateTokens; + dayCacheReadTokens += modelCacheReadTokens; + dayRequests += modelRequests; + + // 按模型计算费用 + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + }; + const modelCostResult = CostCalculator.calculateCost(modelUsage, model); + dayCost += modelCostResult.costs.total; } } - // 计算当天费用(使用通用模型价格估算) - const usage = { - input_tokens: dayInputTokens, - output_tokens: dayOutputTokens, - cache_creation_input_tokens: dayCacheCreateTokens, - cache_read_input_tokens: dayCacheReadTokens - }; - const costResult = CostCalculator.calculateCost(usage, 'unknown'); - dayCost = costResult.costs.total; + // 如果没有模型级别的数据,回退到原始方法 + if (modelKeys.length === 0 && keys.length > 0) { + for (const key of keys) { + const data = await client.hgetall(key); + if (data) { + dayInputTokens += parseInt(data.inputTokens) || 0; + dayOutputTokens += parseInt(data.outputTokens) || 0; + dayRequests += parseInt(data.requests) || 0; + dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + // 使用默认模型价格计算 + const usage = { + input_tokens: dayInputTokens, + output_tokens: dayOutputTokens, + cache_creation_input_tokens: dayCacheCreateTokens, + cache_read_input_tokens: dayCacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'unknown'); + dayCost = costResult.costs.total; + } trendData.push({ date: dateStr, diff --git a/src/routes/web.js b/src/routes/web.js index 8de87952..ac2dbbc6 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -105,9 +105,8 @@ router.post('/auth/login', async (req, res) => { await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout); - // 更新最后登录时间 - adminData.lastLogin = new Date().toISOString(); - await redis.setSession('admin_credentials', adminData); + // 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存 + // init.json 是唯一真实数据源 logger.success(`🔐 Admin login successful: ${username}`); @@ -205,32 +204,49 @@ router.post('/auth/change-password', async (req, res) => { } // 准备更新的数据 - const saltRounds = 10; - const newPasswordHash = await bcrypt.hash(newPassword, saltRounds); + const updatedUsername = newUsername && newUsername.trim() ? newUsername.trim() : adminData.username; - const updatedAdminData = { - ...adminData, - passwordHash: newPasswordHash, - updatedAt: new Date().toISOString() - }; - - // 如果提供了新用户名,则更新用户名 - if (newUsername && newUsername.trim() && newUsername !== adminData.username) { - updatedAdminData.username = newUsername.trim(); - } - - // 更新Redis中的管理员凭据 - await redis.setSession('admin_credentials', updatedAdminData); - - // 更新data/init.json文件 + // 先更新 init.json(唯一真实数据源) const initFilePath = path.join(__dirname, '../../data/init.json'); - if (fs.existsSync(initFilePath)) { + if (!fs.existsSync(initFilePath)) { + return res.status(500).json({ + error: 'Configuration file not found', + message: 'init.json file is missing' + }); + } + + try { const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - initData.adminUsername = updatedAdminData.username; + const oldData = { ...initData }; // 备份旧数据 + + // 更新 init.json + initData.adminUsername = updatedUsername; initData.adminPassword = newPassword; // 保存明文密码到init.json initData.updatedAt = new Date().toISOString(); + // 先写入文件(如果失败则不会影响 Redis) fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)); + + // 文件写入成功后,更新 Redis 缓存 + const saltRounds = 10; + const newPasswordHash = await bcrypt.hash(newPassword, saltRounds); + + const updatedAdminData = { + username: updatedUsername, + passwordHash: newPasswordHash, + createdAt: adminData.createdAt, + lastLogin: adminData.lastLogin, + updatedAt: new Date().toISOString() + }; + + await redis.setSession('admin_credentials', updatedAdminData); + + } catch (fileError) { + logger.error('❌ Failed to update init.json:', fileError); + return res.status(500).json({ + error: 'Update failed', + message: 'Failed to update configuration file' + }); } // 清除当前会话(强制用户重新登录) diff --git a/web/admin/app.js b/web/admin/app.js index 0d21bc4c..f8a57904 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -246,8 +246,11 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 根据当前活跃标签页加载数据 - this.loadCurrentTabData(); + // 预加载账号列表,以便在API Keys页面能正确显示绑定账号名称 + this.loadAccounts().then(() => { + // 根据当前活跃标签页加载数据 + this.loadCurrentTabData(); + }); // 如果在仪表盘,等待Chart.js加载后初始化图表 if (this.activeTab === 'dashboard') { this.waitForChartJS().then(() => { @@ -755,7 +758,11 @@ const app = createApp({ }); break; case 'apiKeys': - this.loadApiKeys(); + // 加载API Keys时同时加载账号列表,以便显示绑定账号名称 + Promise.all([ + this.loadApiKeys(), + this.loadAccounts() + ]); break; case 'accounts': this.loadAccounts();