mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
const express = require('express')
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const axios = require('axios')
|
||
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||
const claudeAccountService = require('../../services/claudeAccountService')
|
||
const redis = require('../../models/redis')
|
||
const { authenticateAdmin } = require('../../middleware/auth')
|
||
const logger = require('../../utils/logger')
|
||
const config = require('../../../config/config')
|
||
|
||
const router = express.Router()
|
||
|
||
// ==================== Claude Code Headers 管理 ====================
|
||
|
||
// 获取所有 Claude Code headers
|
||
router.get('/claude-code-headers', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders()
|
||
|
||
// 获取所有 Claude 账号信息
|
||
const accounts = await claudeAccountService.getAllAccounts()
|
||
const accountMap = {}
|
||
accounts.forEach((account) => {
|
||
accountMap[account.id] = account.name
|
||
})
|
||
|
||
// 格式化输出
|
||
const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({
|
||
accountId,
|
||
accountName: accountMap[accountId] || 'Unknown',
|
||
version: data.version,
|
||
userAgent: data.headers['user-agent'],
|
||
updatedAt: data.updatedAt,
|
||
headers: data.headers
|
||
}))
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: formattedData
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get Claude Code headers:', error)
|
||
return res
|
||
.status(500)
|
||
.json({ error: 'Failed to get Claude Code headers', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 🗑️ 清除指定账号的 Claude Code headers
|
||
router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { accountId } = req.params
|
||
await claudeCodeHeadersService.clearAccountHeaders(accountId)
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: `Claude Code headers cleared for account ${accountId}`
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to clear Claude Code headers:', error)
|
||
return res
|
||
.status(500)
|
||
.json({ error: 'Failed to clear Claude Code headers', message: error.message })
|
||
}
|
||
})
|
||
|
||
// ==================== 系统更新检查 ====================
|
||
|
||
// 版本比较函数
|
||
function compareVersions(current, latest) {
|
||
const parseVersion = (v) => {
|
||
const parts = v.split('.').map(Number)
|
||
return {
|
||
major: parts[0] || 0,
|
||
minor: parts[1] || 0,
|
||
patch: parts[2] || 0
|
||
}
|
||
}
|
||
|
||
const currentV = parseVersion(current)
|
||
const latestV = parseVersion(latest)
|
||
|
||
if (currentV.major !== latestV.major) {
|
||
return currentV.major - latestV.major
|
||
}
|
||
if (currentV.minor !== latestV.minor) {
|
||
return currentV.minor - latestV.minor
|
||
}
|
||
return currentV.patch - latestV.patch
|
||
}
|
||
|
||
router.get('/check-updates', authenticateAdmin, async (req, res) => {
|
||
// 读取当前版本
|
||
const versionPath = path.join(__dirname, '../../../VERSION')
|
||
let currentVersion = '1.0.0'
|
||
try {
|
||
currentVersion = fs.readFileSync(versionPath, 'utf8').trim()
|
||
} catch (err) {
|
||
logger.warn('⚠️ Could not read VERSION file:', err.message)
|
||
}
|
||
|
||
try {
|
||
// 从缓存获取
|
||
const cacheKey = 'version_check_cache'
|
||
const cached = await redis.getClient().get(cacheKey)
|
||
|
||
if (cached && !req.query.force) {
|
||
const cachedData = JSON.parse(cached)
|
||
const cacheAge = Date.now() - cachedData.timestamp
|
||
|
||
// 缓存有效期1小时
|
||
if (cacheAge < 3600000) {
|
||
// 实时计算 hasUpdate,不使用缓存的值
|
||
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
current: currentVersion,
|
||
latest: cachedData.latest,
|
||
hasUpdate, // 实时计算,不用缓存
|
||
releaseInfo: cachedData.releaseInfo,
|
||
cached: true
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 请求 GitHub API
|
||
const githubRepo = 'wei-shaw/claude-relay-service'
|
||
const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, {
|
||
headers: {
|
||
Accept: 'application/vnd.github.v3+json',
|
||
'User-Agent': 'Claude-Relay-Service'
|
||
},
|
||
timeout: 10000
|
||
})
|
||
|
||
const release = response.data
|
||
const latestVersion = release.tag_name.replace(/^v/, '')
|
||
|
||
// 比较版本
|
||
const hasUpdate = compareVersions(currentVersion, latestVersion) < 0
|
||
|
||
const releaseInfo = {
|
||
name: release.name,
|
||
body: release.body,
|
||
publishedAt: release.published_at,
|
||
htmlUrl: release.html_url
|
||
}
|
||
|
||
// 缓存结果(不缓存 hasUpdate,因为它应该实时计算)
|
||
await redis.getClient().set(
|
||
cacheKey,
|
||
JSON.stringify({
|
||
latest: latestVersion,
|
||
releaseInfo,
|
||
timestamp: Date.now()
|
||
}),
|
||
'EX',
|
||
3600
|
||
) // 1小时过期
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
current: currentVersion,
|
||
latest: latestVersion,
|
||
hasUpdate,
|
||
releaseInfo,
|
||
cached: false
|
||
}
|
||
})
|
||
} catch (error) {
|
||
// 改进错误日志记录
|
||
const errorDetails = {
|
||
message: error.message || 'Unknown error',
|
||
code: error.code,
|
||
response: error.response
|
||
? {
|
||
status: error.response.status,
|
||
statusText: error.response.statusText,
|
||
data: error.response.data
|
||
}
|
||
: null,
|
||
request: error.request ? 'Request was made but no response received' : null
|
||
}
|
||
|
||
logger.error('❌ Failed to check for updates:', errorDetails.message)
|
||
|
||
// 处理 404 错误 - 仓库或版本不存在
|
||
if (error.response && error.response.status === 404) {
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
current: currentVersion,
|
||
latest: currentVersion,
|
||
hasUpdate: false,
|
||
releaseInfo: {
|
||
name: 'No releases found',
|
||
body: 'The GitHub repository has no releases yet.',
|
||
publishedAt: new Date().toISOString(),
|
||
htmlUrl: '#'
|
||
},
|
||
warning: 'GitHub repository has no releases'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 如果是网络错误,尝试返回缓存的数据
|
||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||
const cacheKey = 'version_check_cache'
|
||
const cached = await redis.getClient().get(cacheKey)
|
||
|
||
if (cached) {
|
||
const cachedData = JSON.parse(cached)
|
||
// 实时计算 hasUpdate
|
||
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
current: currentVersion,
|
||
latest: cachedData.latest,
|
||
hasUpdate, // 实时计算
|
||
releaseInfo: cachedData.releaseInfo,
|
||
cached: true,
|
||
warning: 'Using cached data due to network error'
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 其他错误返回当前版本信息
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
current: currentVersion,
|
||
latest: currentVersion,
|
||
hasUpdate: false,
|
||
releaseInfo: {
|
||
name: 'Update check failed',
|
||
body: `Unable to check for updates: ${error.message || 'Unknown error'}`,
|
||
publishedAt: new Date().toISOString(),
|
||
htmlUrl: '#'
|
||
},
|
||
error: true,
|
||
warning: error.message || 'Failed to check for updates'
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// ==================== OEM 设置管理 ====================
|
||
|
||
// 获取OEM设置(公开接口,用于显示)
|
||
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||
router.get('/oem-settings', async (req, res) => {
|
||
try {
|
||
const client = redis.getClient()
|
||
const oemSettings = await client.get('oem:settings')
|
||
|
||
// 默认设置
|
||
const defaultSettings = {
|
||
siteName: 'Claude Relay Service',
|
||
siteIcon: '',
|
||
siteIconData: '', // Base64编码的图标数据
|
||
showAdminButton: true, // 是否显示管理后台按钮
|
||
apiStatsNotice: {
|
||
enabled: false,
|
||
title: '',
|
||
content: ''
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
}
|
||
|
||
let settings = defaultSettings
|
||
if (oemSettings) {
|
||
try {
|
||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
|
||
} catch (err) {
|
||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||
}
|
||
}
|
||
|
||
// 添加 LDAP 启用状态到响应中
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
...settings,
|
||
ldapEnabled: config.ldap && config.ldap.enabled === true
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get OEM settings:', error)
|
||
return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 更新OEM设置
|
||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body
|
||
|
||
// 验证输入
|
||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||
return res.status(400).json({ error: 'Site name is required' })
|
||
}
|
||
|
||
if (siteName.length > 100) {
|
||
return res.status(400).json({ error: 'Site name must be less than 100 characters' })
|
||
}
|
||
|
||
// 验证图标数据大小(如果是base64)
|
||
if (siteIconData && siteIconData.length > 500000) {
|
||
// 约375KB
|
||
return res.status(400).json({ error: 'Icon file must be less than 350KB' })
|
||
}
|
||
|
||
// 验证图标URL(如果提供)
|
||
if (siteIcon && !siteIconData) {
|
||
// 简单验证URL格式
|
||
try {
|
||
new URL(siteIcon)
|
||
} catch (err) {
|
||
return res.status(400).json({ error: 'Invalid icon URL format' })
|
||
}
|
||
}
|
||
|
||
const settings = {
|
||
siteName: siteName.trim(),
|
||
siteIcon: (siteIcon || '').trim(),
|
||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||
showAdminButton: showAdminButton !== false, // 默认为true
|
||
apiStatsNotice: {
|
||
enabled: apiStatsNotice?.enabled === true,
|
||
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
}
|
||
|
||
const client = redis.getClient()
|
||
await client.set('oem:settings', JSON.stringify(settings))
|
||
|
||
logger.info(`✅ OEM settings updated: ${siteName}`)
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: 'OEM settings updated successfully',
|
||
data: settings
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update OEM settings:', error)
|
||
return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message })
|
||
}
|
||
})
|
||
|
||
// ==================== Claude Code 版本管理 ====================
|
||
|
||
router.get('/claude-code-version', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||
|
||
// 获取缓存的统一User-Agent
|
||
const unifiedUserAgent = await redis.client.get(CACHE_KEY)
|
||
const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0
|
||
|
||
res.json({
|
||
success: true,
|
||
userAgent: unifiedUserAgent,
|
||
isActive: !!unifiedUserAgent,
|
||
ttlSeconds: ttl,
|
||
lastUpdated: unifiedUserAgent ? new Date().toISOString() : null
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Get unified Claude Code User-Agent error:', error)
|
||
res.status(500).json({
|
||
success: false,
|
||
message: 'Failed to get User-Agent information',
|
||
error: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 🗑️ 清除统一Claude Code User-Agent缓存
|
||
router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||
|
||
// 删除缓存的统一User-Agent
|
||
await redis.client.del(CACHE_KEY)
|
||
|
||
logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`)
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'Unified User-Agent cache cleared successfully'
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Clear unified User-Agent cache error:', error)
|
||
res.status(500).json({
|
||
success: false,
|
||
message: 'Failed to clear cache',
|
||
error: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
module.exports = router
|