mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
Merge branch 'dev' into um-5
This commit is contained in:
11
src/app.js
11
src/app.js
@@ -134,6 +134,17 @@ class Application {
|
||||
// 📝 请求日志(使用自定义logger而不是morgan)
|
||||
this.app.use(requestLogger)
|
||||
|
||||
// 🐛 HTTP调试拦截器(仅在启用调试时生效)
|
||||
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
|
||||
try {
|
||||
const { debugInterceptor } = require('./middleware/debugInterceptor')
|
||||
this.app.use(debugInterceptor)
|
||||
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 基础中间件
|
||||
this.app.use(
|
||||
express.json({
|
||||
|
||||
@@ -733,6 +733,52 @@ class RedisClient {
|
||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||
}
|
||||
|
||||
// 💰 计算账户的每日费用(基于模型使用)
|
||||
async getAccountDailyCost(accountId) {
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const today = getDateStringInTimezone()
|
||||
|
||||
// 获取账户今日所有模型的使用数据
|
||||
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
|
||||
const modelKeys = await this.client.keys(pattern)
|
||||
|
||||
if (!modelKeys || modelKeys.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalCost = 0
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 从key中解析模型名称
|
||||
// 格式:account_usage:model:daily:{accountId}:{model}:{date}
|
||||
const parts = key.split(':')
|
||||
const model = parts[4] // 模型名在第5个位置(索引4)
|
||||
|
||||
// 获取该模型的使用数据
|
||||
const modelUsage = await this.client.hgetall(key)
|
||||
|
||||
if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) {
|
||||
const usage = {
|
||||
input_tokens: parseInt(modelUsage.inputTokens || 0),
|
||||
output_tokens: parseInt(modelUsage.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0)
|
||||
}
|
||||
|
||||
// 使用CostCalculator计算费用
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total
|
||||
|
||||
logger.debug(
|
||||
`💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`)
|
||||
return totalCost
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
const accountKey = `account_usage:${accountId}`
|
||||
@@ -792,10 +838,16 @@ class RedisClient {
|
||||
const dailyData = handleAccountData(daily)
|
||||
const monthlyData = handleAccountData(monthly)
|
||||
|
||||
// 获取每日费用(基于模型使用)
|
||||
const dailyCost = await this.getAccountDailyCost(accountId)
|
||||
|
||||
return {
|
||||
accountId,
|
||||
total: totalData,
|
||||
daily: dailyData,
|
||||
daily: {
|
||||
...dailyData,
|
||||
cost: dailyCost
|
||||
},
|
||||
monthly: monthlyData,
|
||||
averages: {
|
||||
rpm: Math.round(avgRPM * 100) / 100,
|
||||
|
||||
@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const webhookConfigService = require('../services/webhookConfigService')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
// 获取webhook配置
|
||||
router.get('/config', authenticateAdmin, async (req, res) => {
|
||||
@@ -114,27 +115,62 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
// 测试Webhook连通性
|
||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url, type = 'custom', secret, enableSign } = req.body
|
||||
const {
|
||||
url,
|
||||
type = 'custom',
|
||||
secret,
|
||||
enableSign,
|
||||
deviceKey,
|
||||
serverUrl,
|
||||
level,
|
||||
sound,
|
||||
group
|
||||
} = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
// Bark平台特殊处理
|
||||
if (type === 'bark') {
|
||||
if (!deviceKey) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing device key',
|
||||
message: '请提供Bark设备密钥'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证服务器URL(如果提供)
|
||||
if (serverUrl) {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid server URL format',
|
||||
message: '请提供有效的Bark服务器URL'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`)
|
||||
} else {
|
||||
// 其他平台验证URL
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
|
||||
// 创建临时平台配置
|
||||
const platform = {
|
||||
type,
|
||||
@@ -145,21 +181,34 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
// 添加Bark特有字段
|
||||
if (type === 'bark') {
|
||||
platform.deviceKey = deviceKey
|
||||
platform.serverUrl = serverUrl
|
||||
platform.level = level
|
||||
platform.sound = sound
|
||||
platform.group = group
|
||||
}
|
||||
|
||||
const result = await webhookService.testWebhook(platform)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`✅ Webhook测试成功: ${url}`)
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.info(`✅ Webhook测试成功: ${identifier}`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook测试成功',
|
||||
url
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
|
||||
})
|
||||
} else {
|
||||
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
|
||||
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook测试失败',
|
||||
url,
|
||||
url: type === 'bark' ? undefined : url,
|
||||
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
@@ -218,7 +267,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||
errorCode,
|
||||
reason,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
}
|
||||
|
||||
const result = await webhookService.sendNotification(type, testData)
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
@@ -1121,8 +1122,8 @@ class ClaudeAccountService {
|
||||
platform: 'claude-oauth',
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
|
||||
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
|
||||
timestamp: new Date().toISOString()
|
||||
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`,
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||
@@ -1322,7 +1323,7 @@ class ClaudeAccountService {
|
||||
status: 'resumed',
|
||||
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
|
||||
reason: '进入新的5小时窗口,已自动恢复调度',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
@@ -1985,7 +1986,7 @@ class ClaudeAccountService {
|
||||
status: 'warning',
|
||||
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
||||
reason: '5小时使用量接近限制,已自动停止调度',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send webhook notification:', webhookError)
|
||||
|
||||
@@ -369,6 +369,7 @@ class ClaudeConsoleAccountService {
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
@@ -376,7 +377,7 @@ class ClaudeConsoleAccountService {
|
||||
status: 'error',
|
||||
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
|
||||
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||
|
||||
@@ -2,6 +2,7 @@ const axios = require('axios')
|
||||
const crypto = require('crypto')
|
||||
const logger = require('../utils/logger')
|
||||
const webhookConfigService = require('./webhookConfigService')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
|
||||
class WebhookService {
|
||||
constructor() {
|
||||
@@ -206,7 +207,7 @@ class WebhookService {
|
||||
const payload = {
|
||||
type,
|
||||
service: 'claude-relay-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: getISOStringWithTimezone(new Date()),
|
||||
data
|
||||
}
|
||||
|
||||
@@ -357,7 +358,7 @@ class WebhookService {
|
||||
title,
|
||||
color,
|
||||
fields,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: getISOStringWithTimezone(new Date()),
|
||||
footer: {
|
||||
text: 'Claude Relay Service'
|
||||
}
|
||||
@@ -580,7 +581,7 @@ class WebhookService {
|
||||
try {
|
||||
const testData = {
|
||||
message: 'Claude Relay Service webhook测试',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
}
|
||||
|
||||
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
|
||||
|
||||
100
src/utils/dateHelper.js
Normal file
100
src/utils/dateHelper.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const config = require('../../config/config')
|
||||
|
||||
/**
|
||||
* 格式化日期时间为指定时区的本地时间字符串
|
||||
* @param {Date|number} date - Date对象或时间戳(秒或毫秒)
|
||||
* @param {boolean} includeTimezone - 是否在输出中包含时区信息
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
function formatDateWithTimezone(date, includeTimezone = true) {
|
||||
// 处理不同类型的输入
|
||||
let dateObj
|
||||
if (typeof date === 'number') {
|
||||
// 判断是秒还是毫秒时间戳
|
||||
// Unix时间戳(秒)通常小于 10^10,毫秒时间戳通常大于 10^12
|
||||
if (date < 10000000000) {
|
||||
dateObj = new Date(date * 1000) // 秒转毫秒
|
||||
} else {
|
||||
dateObj = new Date(date) // 已经是毫秒
|
||||
}
|
||||
} else if (date instanceof Date) {
|
||||
dateObj = date
|
||||
} else {
|
||||
dateObj = new Date(date)
|
||||
}
|
||||
|
||||
// 获取配置的时区偏移(小时)
|
||||
const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8
|
||||
|
||||
// 计算本地时间
|
||||
const offsetMs = timezoneOffset * 3600000 // 转换为毫秒
|
||||
const localTime = new Date(dateObj.getTime() + offsetMs)
|
||||
|
||||
// 格式化为 YYYY-MM-DD HH:mm:ss
|
||||
const year = localTime.getUTCFullYear()
|
||||
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getUTCDate()).padStart(2, '0')
|
||||
const hours = String(localTime.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
|
||||
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
|
||||
|
||||
let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
|
||||
// 添加时区信息
|
||||
if (includeTimezone) {
|
||||
const sign = timezoneOffset >= 0 ? '+' : ''
|
||||
formattedDate += ` (UTC${sign}${timezoneOffset})`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定时区的ISO格式时间字符串
|
||||
* @param {Date|number} date - Date对象或时间戳
|
||||
* @returns {string} ISO格式的时间字符串
|
||||
*/
|
||||
function getISOStringWithTimezone(date) {
|
||||
// 先获取本地格式的时间(不含时区后缀)
|
||||
const localTimeStr = formatDateWithTimezone(date, false)
|
||||
|
||||
// 获取时区偏移
|
||||
const timezoneOffset = config.system.timezoneOffset || 8
|
||||
|
||||
// 构建ISO格式,添加时区偏移
|
||||
const sign = timezoneOffset >= 0 ? '+' : '-'
|
||||
const absOffset = Math.abs(timezoneOffset)
|
||||
const offsetHours = String(Math.floor(absOffset)).padStart(2, '0')
|
||||
const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0')
|
||||
|
||||
// 将空格替换为T,并添加时区
|
||||
return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算时间差并格式化为人类可读的字符串
|
||||
* @param {number} seconds - 秒数
|
||||
* @returns {string} 格式化的时间差字符串
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}分钟`
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
} else {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDateWithTimezone,
|
||||
getISOStringWithTimezone,
|
||||
formatDuration
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
const logger = require('./logger')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const { getISOStringWithTimezone } = require('./dateHelper')
|
||||
|
||||
class WebhookNotifier {
|
||||
constructor() {
|
||||
@@ -28,7 +29,7 @@ class WebhookNotifier {
|
||||
errorCode:
|
||||
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
|
||||
reason: notification.reason,
|
||||
timestamp: notification.timestamp || new Date().toISOString()
|
||||
timestamp: notification.timestamp || getISOStringWithTimezone(new Date())
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send account anomaly notification:', error)
|
||||
|
||||
Reference in New Issue
Block a user