mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
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:
57
src/app.js
57
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
// 清除当前会话(强制用户重新登录)
|
||||
|
||||
Reference in New Issue
Block a user