fix: 统一管理员密码管理机制,以init.json为唯一数据源

- app.js: 每次启动强制从init.json加载管理员凭据到Redis,确保数据一致性
- web.js: 修改密码时先更新init.json,成功后再更新Redis缓存
- cli/index.js: CLI创建管理员时同时更新init.json和Redis
- setup.js: 优化提示信息,明确重置密码需要重启服务
- admin.js: 修复Claude账户专属绑定功能的验证逻辑

解决了之前存在的双重存储同步问题,现在init.json是唯一真实数据源。

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-17 20:57:31 +08:00
parent ee9bd4aea4
commit 11873ed78b
6 changed files with 188 additions and 74 deletions

View File

@@ -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('创建管理员账户失败');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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();
// 先更新 init.json唯一真实数据源
const initFilePath = path.join(__dirname, '../../data/init.json');
if (!fs.existsSync(initFilePath)) {
return res.status(500).json({
error: 'Configuration file not found',
message: 'init.json file is missing'
});
}
// 更新Redis中的管理员凭据
await redis.setSession('admin_credentials', updatedAdminData);
// 更新data/init.json文件
const initFilePath = path.join(__dirname, '../../data/init.json');
if (fs.existsSync(initFilePath)) {
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'
});
}
// 清除当前会话(强制用户重新登录)

View File

@@ -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();