mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加多模型支持和OpenAI兼容接口
- 新增 Gemini 模型支持和账户管理功能 - 实现 OpenAI 格式到 Claude/Gemini 的请求转换 - 添加自动 token 刷新服务,支持提前刷新策略 - 增强 Web 管理界面,支持 Gemini 账户管理 - 优化 token 显示,添加掩码功能 - 完善日志记录和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -148,7 +148,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
|
||||
const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://claude.ai/',
|
||||
|
||||
95
src/utils/tokenMask.js
Normal file
95
src/utils/tokenMask.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Token 脱敏工具
|
||||
* 用于在日志中安全显示 token,只显示70%的内容,其余用*代替
|
||||
*/
|
||||
|
||||
/**
|
||||
* 对 token 进行脱敏处理
|
||||
* @param {string} token - 需要脱敏的 token
|
||||
* @param {number} visiblePercent - 可见部分的百分比,默认 70
|
||||
* @returns {string} 脱敏后的 token
|
||||
*/
|
||||
function maskToken(token, visiblePercent = 70) {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return '[EMPTY]';
|
||||
}
|
||||
|
||||
const length = token.length;
|
||||
|
||||
// 对于非常短的 token,至少隐藏一部分
|
||||
if (length <= 10) {
|
||||
return token.slice(0, 5) + '*'.repeat(length - 5);
|
||||
}
|
||||
|
||||
// 计算可见字符数量
|
||||
const visibleLength = Math.floor(length * (visiblePercent / 100));
|
||||
|
||||
// 在前部和尾部分配可见字符
|
||||
const frontLength = Math.ceil(visibleLength * 0.6);
|
||||
const backLength = visibleLength - frontLength;
|
||||
|
||||
// 构建脱敏后的 token
|
||||
const front = token.slice(0, frontLength);
|
||||
const back = token.slice(-backLength);
|
||||
const middle = '*'.repeat(length - visibleLength);
|
||||
|
||||
return `${front}${middle}${back}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对包含 token 的对象进行脱敏处理
|
||||
* @param {Object} obj - 包含 token 的对象
|
||||
* @param {Array<string>} tokenFields - 需要脱敏的字段名列表
|
||||
* @returns {Object} 脱敏后的对象副本
|
||||
*/
|
||||
function maskTokensInObject(obj, tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token']) {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const masked = { ...obj };
|
||||
|
||||
tokenFields.forEach(field => {
|
||||
if (masked[field]) {
|
||||
masked[field] = maskToken(masked[field]);
|
||||
}
|
||||
});
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 token 刷新日志
|
||||
* @param {string} accountId - 账户 ID
|
||||
* @param {string} accountName - 账户名称
|
||||
* @param {Object} tokens - 包含 access_token 和 refresh_token 的对象
|
||||
* @param {string} status - 刷新状态 (success/failed)
|
||||
* @param {string} message - 额外的消息
|
||||
* @returns {Object} 格式化的日志对象
|
||||
*/
|
||||
function formatTokenRefreshLog(accountId, accountName, tokens, status, message = '') {
|
||||
const log = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event: 'token_refresh',
|
||||
accountId,
|
||||
accountName,
|
||||
status,
|
||||
message
|
||||
};
|
||||
|
||||
if (tokens) {
|
||||
log.tokens = {
|
||||
accessToken: tokens.accessToken ? maskToken(tokens.accessToken) : '[NOT_PROVIDED]',
|
||||
refreshToken: tokens.refreshToken ? maskToken(tokens.refreshToken) : '[NOT_PROVIDED]',
|
||||
expiresAt: tokens.expiresAt || '[NOT_PROVIDED]'
|
||||
};
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maskToken,
|
||||
maskTokensInObject,
|
||||
formatTokenRefreshLog
|
||||
};
|
||||
178
src/utils/tokenRefreshLogger.js
Normal file
178
src/utils/tokenRefreshLogger.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { maskToken, formatTokenRefreshLog } = require('./tokenMask');
|
||||
|
||||
// 确保日志目录存在
|
||||
const logDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建专用的 token 刷新日志记录器
|
||||
const tokenRefreshLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss.SSS'
|
||||
}),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
return JSON.stringify(info, null, 2);
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
// 文件传输 - 每日轮转
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'token-refresh.log'),
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 30, // 保留30天
|
||||
tailable: true
|
||||
}),
|
||||
// 错误单独记录
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'token-refresh-error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024,
|
||||
maxFiles: 30
|
||||
})
|
||||
],
|
||||
// 错误处理
|
||||
exitOnError: false
|
||||
});
|
||||
|
||||
// 在开发环境添加控制台输出
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
tokenRefreshLogger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 token 刷新开始
|
||||
*/
|
||||
function logRefreshStart(accountId, accountName, platform = 'claude', reason = '') {
|
||||
tokenRefreshLogger.info({
|
||||
event: 'token_refresh_start',
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 token 刷新成功
|
||||
*/
|
||||
function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenData = {}) {
|
||||
const maskedTokenData = {
|
||||
accessToken: tokenData.accessToken ? maskToken(tokenData.accessToken) : '[NOT_PROVIDED]',
|
||||
refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]',
|
||||
expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]',
|
||||
scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]'
|
||||
};
|
||||
|
||||
tokenRefreshLogger.info({
|
||||
event: 'token_refresh_success',
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
tokenData: maskedTokenData,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 token 刷新失败
|
||||
*/
|
||||
function logRefreshError(accountId, accountName, platform = 'claude', error, attemptNumber = 1) {
|
||||
const errorInfo = {
|
||||
message: error.message || error.toString(),
|
||||
code: error.code || 'UNKNOWN',
|
||||
statusCode: error.response?.status || 'N/A',
|
||||
responseData: error.response?.data || 'N/A'
|
||||
};
|
||||
|
||||
tokenRefreshLogger.error({
|
||||
event: 'token_refresh_error',
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
error: errorInfo,
|
||||
attemptNumber,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 token 刷新跳过(由于并发锁)
|
||||
*/
|
||||
function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = 'locked') {
|
||||
tokenRefreshLogger.info({
|
||||
event: 'token_refresh_skipped',
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 token 使用情况
|
||||
*/
|
||||
function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, isExpired) {
|
||||
tokenRefreshLogger.debug({
|
||||
event: 'token_usage_check',
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
expiresAt,
|
||||
isExpired,
|
||||
remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录批量刷新任务
|
||||
*/
|
||||
function logBatchRefreshStart(totalAccounts, platform = 'all') {
|
||||
tokenRefreshLogger.info({
|
||||
event: 'batch_refresh_start',
|
||||
totalAccounts,
|
||||
platform,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录批量刷新结果
|
||||
*/
|
||||
function logBatchRefreshComplete(results) {
|
||||
tokenRefreshLogger.info({
|
||||
event: 'batch_refresh_complete',
|
||||
results: {
|
||||
total: results.total || 0,
|
||||
success: results.success || 0,
|
||||
failed: results.failed || 0,
|
||||
skipped: results.skipped || 0
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logger: tokenRefreshLogger,
|
||||
logRefreshStart,
|
||||
logRefreshSuccess,
|
||||
logRefreshError,
|
||||
logRefreshSkipped,
|
||||
logTokenUsage,
|
||||
logBatchRefreshStart,
|
||||
logBatchRefreshComplete
|
||||
};
|
||||
Reference in New Issue
Block a user