refactor(potluck): 简化 API 大锅饭系统并增强安全性和 UI
- 移除凭证管理和资源包系统,简化为基于每日限额的 Key 管理 - 新增登录安全防护(频率限制、账户锁定、IP 追踪) - 重构日志系统使用 AsyncLocalStorage 替代全局状态 - 全面升级 UI 界面(主题切换、使用分布统计、响应式设计) - 优化安装脚本(PowerShell 支持、手动安装指引) BREAKING CHANGE: API Potluck 插件不再支持凭证资源包功能,所有 Key 仅基于每日限额进行配额管理。user-data-manager 模块已禁用,相关 API 端点已移除。
This commit is contained in:
parent
8456f64615
commit
fa19bae517
28 changed files with 1878 additions and 4173 deletions
|
|
@ -138,6 +138,13 @@ docker compose up -d
|
||||||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||||
* **Windows**: `install-and-run.bat` をダブルクリックして実行
|
* **Windows**: `install-and-run.bat` をダブルクリックして実行
|
||||||
|
|
||||||
|
> **💡 スクリプトの実行に失敗した場合は、手動で依存関係をインストールして起動できます:**
|
||||||
|
> ```bash
|
||||||
|
> npm install
|
||||||
|
> npm start
|
||||||
|
> ```
|
||||||
|
|
||||||
|
|
||||||
#### 2. コンソールへのアクセス
|
#### 2. コンソールへのアクセス
|
||||||
サーバー起動後、ブラウザで以下にアクセスしてください:
|
サーバー起動後、ブラウザで以下にアクセスしてください:
|
||||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,13 @@ docker compose up -d
|
||||||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||||
* **Windows**: 双击运行 `install-and-run.bat`
|
* **Windows**: 双击运行 `install-and-run.bat`
|
||||||
|
|
||||||
|
> **💡 如果脚本运行失败,可以尝试手动安装依赖并启动:**
|
||||||
|
> ```bash
|
||||||
|
> npm install
|
||||||
|
> npm start
|
||||||
|
> ```
|
||||||
|
|
||||||
|
|
||||||
#### 2. 访问控制台
|
#### 2. 访问控制台
|
||||||
服务器启动后,打开浏览器访问:
|
服务器启动后,打开浏览器访问:
|
||||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,13 @@ To build from source instead of using the pre-built image, edit `docker-compose.
|
||||||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||||
* **Windows**: Double-click `install-and-run.bat`
|
* **Windows**: Double-click `install-and-run.bat`
|
||||||
|
|
||||||
|
> **💡 If the script fails, you can try manually installing dependencies and starting:**
|
||||||
|
> ```bash
|
||||||
|
> npm install
|
||||||
|
> npm start
|
||||||
|
> ```
|
||||||
|
|
||||||
|
|
||||||
#### 2. Access the console
|
#### 2. Access the console
|
||||||
After the server starts, open your browser and visit:
|
After the server starts, open your browser and visit:
|
||||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,16 @@
|
||||||
@echo off
|
@echo off
|
||||||
rem Ensure the script uses Windows CRLF line endings and UTF-8 encoding
|
setlocal
|
||||||
chcp 65001 >nul
|
cd /d "%~dp0"
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
:: 处理参数
|
:: Check for powershell
|
||||||
set FORCE_PULL=0
|
where powershell >nul 2>&1
|
||||||
|
|
||||||
for %%a in (%*) do (
|
|
||||||
if "%%a"=="--pull" set FORCE_PULL=1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ========================================
|
|
||||||
echo AI Client 2 API 快速安装启动脚本
|
|
||||||
echo ========================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 检查Git并尝试pull
|
|
||||||
if !FORCE_PULL! equ 1 (
|
|
||||||
echo [更新] 正在从远程仓库拉取最新代码...
|
|
||||||
git --version >nul 2>&1
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
git pull
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [警告] Git pull 失败,请检查网络或手动处理冲突。
|
|
||||||
) else (
|
|
||||||
echo [成功] 代码已更新。
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo [警告] 未检测到 Git,跳过代码拉取。
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 检查Node.js是否已安装
|
|
||||||
echo [检查] 正在检查Node.js是否已安装...
|
|
||||||
node --version >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
if %errorlevel% neq 0 (
|
||||||
echo [错误] 未检测到Node.js,请先安装Node.js
|
echo [ERROR] PowerShell not found. Please run 'node src/core/master.js' manually.
|
||||||
echo 下载地址:https://nodejs.org/
|
|
||||||
echo 提示:推荐安装LTS版本
|
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 获取Node.js版本
|
:: Launch PowerShell script
|
||||||
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-and-run.ps1" %*
|
||||||
echo [成功] Node.js已安装,版本: !NODE_VERSION!
|
|
||||||
|
|
||||||
:: 检查package.json是否存在
|
endlocal
|
||||||
if not exist "package.json" (
|
|
||||||
echo [错误] 未找到package.json文件
|
|
||||||
echo 请确保在项目根目录下运行此脚本
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [成功] 找到package.json文件
|
|
||||||
|
|
||||||
:: 检查 pnpm 是否安装
|
|
||||||
where pnpm >nul 2>&1
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
set PKG_MANAGER=pnpm
|
|
||||||
) else (
|
|
||||||
set PKG_MANAGER=npm
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [安装] 正在使用 !PKG_MANAGER! 安装/更新依赖...
|
|
||||||
echo 这可能需要几分钟时间,请耐心等待...
|
|
||||||
echo 正在执行: !PKG_MANAGER! install...
|
|
||||||
|
|
||||||
call !PKG_MANAGER! install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [错误] 依赖安装失败
|
|
||||||
echo 请检查网络连接或手动运行 '!PKG_MANAGER! install'
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo [成功] 依赖安装/更新完成
|
|
||||||
|
|
||||||
:: 检查src目录和master.js是否存在
|
|
||||||
if not exist "src\core\master.js" (
|
|
||||||
echo [错误] 未找到src\core\master.js文件
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [成功] 项目文件检查完成
|
|
||||||
|
|
||||||
:: 启动应用程序
|
|
||||||
echo.
|
|
||||||
echo ========================================
|
|
||||||
echo 启动AIClient2API服务器...
|
|
||||||
echo ========================================
|
|
||||||
echo.
|
|
||||||
echo 服务器将在 http://localhost:3000 启动
|
|
||||||
echo 访问 http://localhost:3000 查看管理界面
|
|
||||||
echo 按 Ctrl+C 停止服务器
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 启动服务器
|
|
||||||
node src\core\master.js
|
|
||||||
|
|
|
||||||
71
install-and-run.ps1
Normal file
71
install-and-run.ps1
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# install-and-run.ps1
|
||||||
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " AI Client 2 API 快速安装启动脚本" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 处理参数
|
||||||
|
$forcePull = $args -contains "--pull"
|
||||||
|
|
||||||
|
# 检查 Git 并拉取
|
||||||
|
if ($forcePull) {
|
||||||
|
Write-Host "[更新] 正在从远程仓库拉取最新代码..."
|
||||||
|
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||||
|
git pull
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning "Git pull 失败,请检查网络或手动处理冲突。"
|
||||||
|
} else {
|
||||||
|
Write-Host "[成功] 代码已更新。" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "未检测到 Git,跳过代码拉取。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 Node.js
|
||||||
|
Write-Host "[检查] 正在检查Node.js是否已安装..."
|
||||||
|
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "[错误] 未检测到Node.js,请先安装Node.js (https://nodejs.org/)" -ForegroundColor Red
|
||||||
|
Pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeVersion = node --version
|
||||||
|
Write-Host "[成功] Node.js已安装,版本: $nodeVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 检查 package.json
|
||||||
|
if (-not (Test-Path "package.json")) {
|
||||||
|
Write-Host "[错误] 未找到package.json文件,请确保在项目根目录下运行此脚本" -ForegroundColor Red
|
||||||
|
Pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确定包管理器
|
||||||
|
$pkgManager = if (Get-Command pnpm -ErrorAction SilentlyContinue) { "pnpm" } else { "npm" }
|
||||||
|
Write-Host "[安装] 正在使用 $pkgManager 安装/更新依赖..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
& $pkgManager install
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[错误] 依赖安装失败,请检查网络连接。" -ForegroundColor Red
|
||||||
|
Pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查主文件
|
||||||
|
if (-not (Test-Path "src\core\master.js")) {
|
||||||
|
Write-Host "[错误] 未找到 src\core\master.js 文件" -ForegroundColor Red
|
||||||
|
Pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " 启动 AIClient2API 服务器..." -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host "服务器将在 http://localhost:3000 启动"
|
||||||
|
Write-Host "按 Ctrl+C 停止服务器"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
node src\core\master.js
|
||||||
|
|
@ -54,50 +54,58 @@ function normalizeConfiguredProviders(config) {
|
||||||
* @returns {Object} The initialized configuration object.
|
* @returns {Object} The initialized configuration object.
|
||||||
*/
|
*/
|
||||||
export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'configs/config.json') {
|
export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'configs/config.json') {
|
||||||
let currentConfig = {};
|
const defaultConfig = {
|
||||||
|
REQUIRED_API_KEY: "123456",
|
||||||
|
SERVER_PORT: 3000,
|
||||||
|
HOST: '0.0.0.0',
|
||||||
|
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
|
||||||
|
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
||||||
|
SYSTEM_PROMPT_MODE: 'append',
|
||||||
|
PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
|
||||||
|
PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth']
|
||||||
|
PROMPT_LOG_BASE_NAME: "prompt_log",
|
||||||
|
PROMPT_LOG_MODE: "none",
|
||||||
|
REQUEST_MAX_RETRIES: 3,
|
||||||
|
REQUEST_BASE_DELAY: 1000,
|
||||||
|
CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证)
|
||||||
|
CRON_NEAR_MINUTES: 15,
|
||||||
|
CRON_REFRESH_TOKEN: false,
|
||||||
|
LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时
|
||||||
|
LOGIN_MAX_ATTEMPTS: 5, // 最大失败重试次数
|
||||||
|
LOGIN_LOCKOUT_DURATION: 1800, // 锁定持续时间(秒),默认30分钟
|
||||||
|
LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒
|
||||||
|
PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径
|
||||||
|
MAX_ERROR_COUNT: 10, // 提供商最大错误次数
|
||||||
|
providerFallbackChain: {}, // 跨类型 Fallback 链配置
|
||||||
|
LOG_ENABLED: true,
|
||||||
|
LOG_OUTPUT_MODE: "all",
|
||||||
|
LOG_LEVEL: "info",
|
||||||
|
LOG_DIR: "logs",
|
||||||
|
LOG_INCLUDE_REQUEST_ID: true,
|
||||||
|
LOG_INCLUDE_TIMESTAMP: true,
|
||||||
|
LOG_MAX_FILE_SIZE: 10485760,
|
||||||
|
LOG_MAX_FILES: 10,
|
||||||
|
TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制)
|
||||||
|
TLS_SIDECAR_PORT: 9090, // sidecar 监听端口
|
||||||
|
TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索)
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentConfig = { ...defaultConfig };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configData = fs.readFileSync(configFilePath, 'utf8');
|
const configData = fs.readFileSync(configFilePath, 'utf8');
|
||||||
currentConfig = JSON.parse(configData);
|
const loadedConfig = JSON.parse(configData);
|
||||||
|
Object.assign(currentConfig, loadedConfig);
|
||||||
logger.info('[Config] Loaded configuration from configs/config.json');
|
logger.info('[Config] Loaded configuration from configs/config.json');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Config Error] Failed to load configs/config.json:', error.message);
|
if (error.code !== 'ENOENT') {
|
||||||
// Fallback to default values if config.json is not found or invalid
|
logger.error('[Config Error] Failed to load configs/config.json:', error.message);
|
||||||
currentConfig = {
|
} else {
|
||||||
REQUIRED_API_KEY: "123456",
|
logger.info('[Config] configs/config.json not found, using default configuration.');
|
||||||
SERVER_PORT: 3000,
|
}
|
||||||
HOST: '0.0.0.0',
|
|
||||||
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
|
|
||||||
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
|
||||||
SYSTEM_PROMPT_MODE: 'append',
|
|
||||||
PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
|
|
||||||
PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth']
|
|
||||||
PROMPT_LOG_BASE_NAME: "prompt_log",
|
|
||||||
PROMPT_LOG_MODE: "none",
|
|
||||||
REQUEST_MAX_RETRIES: 3,
|
|
||||||
REQUEST_BASE_DELAY: 1000,
|
|
||||||
CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证)
|
|
||||||
CRON_NEAR_MINUTES: 15,
|
|
||||||
CRON_REFRESH_TOKEN: false,
|
|
||||||
LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时
|
|
||||||
PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径
|
|
||||||
MAX_ERROR_COUNT: 10, // 提供商最大错误次数
|
|
||||||
providerFallbackChain: {}, // 跨类型 Fallback 链配置
|
|
||||||
LOG_ENABLED: true,
|
|
||||||
LOG_OUTPUT_MODE: "all",
|
|
||||||
LOG_LEVEL: "info",
|
|
||||||
LOG_DIR: "logs",
|
|
||||||
LOG_INCLUDE_REQUEST_ID: true,
|
|
||||||
LOG_INCLUDE_TIMESTAMP: true,
|
|
||||||
LOG_MAX_FILE_SIZE: 10485760,
|
|
||||||
LOG_MAX_FILES: 10,
|
|
||||||
TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制)
|
|
||||||
TLS_SIDECAR_PORT: 9090, // sidecar 监听端口
|
|
||||||
TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索)
|
|
||||||
};
|
|
||||||
logger.info('[Config] Using default configuration.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// CLI argument definitions: { flag, configKey, type, validValues? }
|
// CLI argument definitions: { flag, configKey, type, validValues? }
|
||||||
// type: 'string' | 'int' | 'bool' | 'enum'
|
// type: 'string' | 'int' | 'bool' | 'enum'
|
||||||
const cliArgDefs = [
|
const cliArgDefs = [
|
||||||
|
|
@ -113,6 +121,9 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
|
||||||
{ flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' },
|
{ flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' },
|
||||||
{ flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' },
|
{ flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' },
|
||||||
{ flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' },
|
{ flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' },
|
||||||
|
{ flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' },
|
||||||
|
{ flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' },
|
||||||
|
{ flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Parse command-line arguments using definitions
|
// Parse command-line arguments using definitions
|
||||||
|
|
|
||||||
|
|
@ -50,218 +50,222 @@ export function createRequestHandler(config, providerPoolManager) {
|
||||||
// Generate unique request ID and set it in logger context
|
// Generate unique request ID and set it in logger context
|
||||||
const clientIp = getClientIp(req);
|
const clientIp = getClientIp(req);
|
||||||
const requestId = `${clientIp}:${generateRequestId()}`;
|
const requestId = `${clientIp}:${generateRequestId()}`;
|
||||||
logger.setRequestContext(requestId);
|
|
||||||
|
|
||||||
// Deep copy the config for each request to allow dynamic modification
|
return logger.runWithContext(requestId, async () => {
|
||||||
const currentConfig = deepmerge({}, config);
|
// Deep copy the config for each request to allow dynamic modification
|
||||||
|
const currentConfig = deepmerge({}, config);
|
||||||
// 计算当前请求的基础 URL
|
|
||||||
const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
// 计算当前请求的基础 URL
|
||||||
const host = req.headers.host;
|
const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
||||||
currentConfig.requestBaseUrl = `${protocol}://${host}`;
|
const host = req.headers.host;
|
||||||
|
currentConfig.requestBaseUrl = `${protocol}://${host}`;
|
||||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
||||||
let path = requestUrl.pathname;
|
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||||
const method = req.method;
|
let path = requestUrl.pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
// Set CORS headers for all requests
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin');
|
|
||||||
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight
|
|
||||||
|
|
||||||
// Handle CORS preflight requests
|
|
||||||
if (method === 'OPTIONS') {
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve static files for UI (除了登录页面需要认证)
|
|
||||||
// 检查是否是插件静态文件
|
|
||||||
const pluginManager = getPluginManager();
|
|
||||||
const isPluginStatic = pluginManager.isPluginStaticPath(path);
|
|
||||||
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) {
|
|
||||||
const served = await serveStaticFiles(path, res);
|
|
||||||
if (served) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行插件路由
|
|
||||||
const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res);
|
|
||||||
if (pluginRouteHandled) return;
|
|
||||||
|
|
||||||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
|
||||||
if (uiHandled) return;
|
|
||||||
|
|
||||||
// logger.info(`\n${new Date().toLocaleString()}`);
|
|
||||||
logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
if (method === 'GET' && path === '/health') {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
status: 'healthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
provider: currentConfig.MODEL_PROVIDER
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grok assets proxy endpoint
|
|
||||||
if (method === 'GET' && path === '/api/grok/assets') {
|
|
||||||
await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// providers health endpoint
|
|
||||||
// url params: provider[string], customName[string], unhealthRatioThreshold[float]
|
|
||||||
// 支持provider, customName过滤记录
|
|
||||||
// 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false
|
|
||||||
if (method === 'GET' && path === '/provider_health') {
|
|
||||||
try {
|
try {
|
||||||
const provider = requestUrl.searchParams.get('provider');
|
// Set CORS headers for all requests
|
||||||
const customName = requestUrl.searchParams.get('customName');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold');
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||||
unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold);
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin');
|
||||||
let provideStatus = await getProviderStatus(currentConfig, { provider, customName });
|
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight
|
||||||
let summaryHealth = true;
|
|
||||||
if (!isNaN(unhealthRatioThreshold)) {
|
// Handle CORS preflight requests
|
||||||
summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold;
|
if (method === 'OPTIONS') {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files for UI (除了登录页面需要认证)
|
||||||
|
// 检查是否是插件静态文件
|
||||||
|
const pluginManager = getPluginManager();
|
||||||
|
const isPluginStatic = pluginManager.isPluginStaticPath(path);
|
||||||
|
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) {
|
||||||
|
const served = await serveStaticFiles(path, res);
|
||||||
|
if (served) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行插件路由
|
||||||
|
const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res);
|
||||||
|
if (pluginRouteHandled) return;
|
||||||
|
|
||||||
|
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||||
|
if (uiHandled) return;
|
||||||
|
|
||||||
|
// logger.info(`\n${new Date().toLocaleString()}`);
|
||||||
|
logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
if (method === 'GET' && path === '/health') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: currentConfig.MODEL_PROVIDER
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grok assets proxy endpoint
|
||||||
|
if (method === 'GET' && path === '/api/grok/assets') {
|
||||||
|
await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// providers health endpoint
|
||||||
|
// url params: provider[string], customName[string], unhealthRatioThreshold[float]
|
||||||
|
// 支持provider, customName过滤记录
|
||||||
|
// 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false
|
||||||
|
if (method === 'GET' && path === '/provider_health') {
|
||||||
|
try {
|
||||||
|
const provider = requestUrl.searchParams.get('provider');
|
||||||
|
const customName = requestUrl.searchParams.get('customName');
|
||||||
|
let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold');
|
||||||
|
unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold);
|
||||||
|
let provideStatus = await getProviderStatus(currentConfig, { provider, customName });
|
||||||
|
let summaryHealth = true;
|
||||||
|
if (!isNaN(unhealthRatioThreshold)) {
|
||||||
|
summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
items: provideStatus.providerPoolsSlim,
|
||||||
|
count: provideStatus.count,
|
||||||
|
unhealthyCount: provideStatus.unhealthyCount,
|
||||||
|
unhealthyRatio: provideStatus.unhealthyRatio,
|
||||||
|
unhealthySummeryMessage: provideStatus.unhealthySummeryMessage,
|
||||||
|
summaryHealth
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`[Server] req provider_health error: ${error.message}`);
|
||||||
|
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
items: provideStatus.providerPoolsSlim,
|
|
||||||
count: provideStatus.count,
|
|
||||||
unhealthyCount: provideStatus.unhealthyCount,
|
|
||||||
unhealthyRatio: provideStatus.unhealthyRatio,
|
|
||||||
unhealthySummeryMessage: provideStatus.unhealthySummeryMessage,
|
|
||||||
summaryHealth
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.info(`[Server] req provider_health error: ${error.message}`);
|
|
||||||
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Handle API requests
|
// Handle API requests
|
||||||
// Allow overriding MODEL_PROVIDER via request header
|
// Allow overriding MODEL_PROVIDER via request header
|
||||||
const modelProviderHeader = req.headers['model-provider'];
|
const modelProviderHeader = req.headers['model-provider'];
|
||||||
if (modelProviderHeader) {
|
if (modelProviderHeader) {
|
||||||
const registeredProviders = getRegisteredProviders();
|
const registeredProviders = getRegisteredProviders();
|
||||||
if (registeredProviders.includes(modelProviderHeader)) {
|
if (registeredProviders.includes(modelProviderHeader)) {
|
||||||
currentConfig.MODEL_PROVIDER = modelProviderHeader;
|
currentConfig.MODEL_PROVIDER = modelProviderHeader;
|
||||||
logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
|
logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`);
|
logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`);
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } }));
|
res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
|
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
|
||||||
const pathSegments = path.split('/').filter(segment => segment.length > 0);
|
const pathSegments = path.split('/').filter(segment => segment.length > 0);
|
||||||
|
|
||||||
if (pathSegments.length > 0) {
|
if (pathSegments.length > 0) {
|
||||||
const firstSegment = pathSegments[0];
|
const firstSegment = pathSegments[0];
|
||||||
const registeredProviders = getRegisteredProviders();
|
const registeredProviders = getRegisteredProviders();
|
||||||
const isValidProvider = registeredProviders.includes(firstSegment);
|
const isValidProvider = registeredProviders.includes(firstSegment);
|
||||||
const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO;
|
const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO;
|
||||||
|
|
||||||
if (firstSegment && (isValidProvider || isAutoMode)) {
|
if (firstSegment && (isValidProvider || isAutoMode)) {
|
||||||
currentConfig.MODEL_PROVIDER = firstSegment;
|
currentConfig.MODEL_PROVIDER = firstSegment;
|
||||||
logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
|
logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
|
||||||
pathSegments.shift();
|
pathSegments.shift();
|
||||||
path = '/' + pathSegments.join('/');
|
path = '/' + pathSegments.join('/');
|
||||||
requestUrl.pathname = path;
|
requestUrl.pathname = path;
|
||||||
} else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) {
|
} else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) {
|
||||||
// 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错
|
// 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错
|
||||||
logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`);
|
logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`);
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } }));
|
res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } }));
|
||||||
return;
|
return;
|
||||||
} else if (firstSegment && !isValidProvider) {
|
} else if (firstSegment && !isValidProvider) {
|
||||||
logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
|
logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 执行认证流程(只有 type='auth' 的插件参与)
|
// 1. 执行认证流程(只有 type='auth' 的插件参与)
|
||||||
const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig);
|
const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig);
|
||||||
if (authResult.handled) {
|
if (authResult.handled) {
|
||||||
// 认证插件已处理请求(如发送了错误响应)
|
// 认证插件已处理请求(如发送了错误响应)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!authResult.authorized) {
|
if (!authResult.authorized) {
|
||||||
// 没有认证插件授权,返回 401
|
// 没有认证插件授权,返回 401
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 执行普通中间件(type!='auth' 的插件)
|
// 2. 执行普通中间件(type!='auth' 的插件)
|
||||||
const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig);
|
const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig);
|
||||||
if (middlewareResult.handled) {
|
if (middlewareResult.handled) {
|
||||||
// 中间件已处理请求
|
// 中间件已处理请求
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle count_tokens requests (Anthropic API compatible)
|
// Handle count_tokens requests (Anthropic API compatible)
|
||||||
if (path.includes('/count_tokens') && method === 'POST') {
|
if (path.includes('/count_tokens') && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseRequestBody(req);
|
const body = await parseRequestBody(req);
|
||||||
logger.info(`[Server] Handling count_tokens request for model: ${body.model}`);
|
logger.info(`[Server] Handling count_tokens request for model: ${body.model}`);
|
||||||
|
|
||||||
|
// Use common utility method directly
|
||||||
|
try {
|
||||||
|
const result = countTokensAnthropic(body);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (tokenError) {
|
||||||
|
logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`);
|
||||||
|
// Last resort: return 0
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ input_tokens: 0 }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Server] count_tokens error: ${error.message}`);
|
||||||
|
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取或选择 API Service 实例
|
||||||
|
let apiService;
|
||||||
|
// try {
|
||||||
|
// apiService = await getApiService(currentConfig);
|
||||||
|
// } catch (error) {
|
||||||
|
// handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||||
|
// const poolManager = getProviderPoolManager();
|
||||||
|
// if (poolManager) {
|
||||||
|
// poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
|
||||||
|
// uuid: currentConfig.uuid
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
// Use common utility method directly
|
|
||||||
try {
|
try {
|
||||||
const result = countTokensAnthropic(body);
|
// Handle API requests
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
|
||||||
res.end(JSON.stringify(result));
|
if (apiHandled) return;
|
||||||
} catch (tokenError) {
|
|
||||||
logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`);
|
// Fallback for unmatched routes
|
||||||
// Last resort: return 0
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||||
res.end(JSON.stringify({ input_tokens: 0 }));
|
} catch (error) {
|
||||||
|
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
||||||
}
|
}
|
||||||
return true;
|
} finally {
|
||||||
} catch (error) {
|
// Clear request context after request is complete
|
||||||
logger.error(`[Server] count_tokens error: ${error.message}`);
|
logger.clearRequestContext(requestId);
|
||||||
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// 获取或选择 API Service 实例
|
|
||||||
let apiService;
|
|
||||||
// try {
|
|
||||||
// apiService = await getApiService(currentConfig);
|
|
||||||
// } catch (error) {
|
|
||||||
// handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
|
||||||
// const poolManager = getProviderPoolManager();
|
|
||||||
// if (poolManager) {
|
|
||||||
// poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
|
|
||||||
// uuid: currentConfig.uuid
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Handle API requests
|
|
||||||
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
|
|
||||||
if (apiHandled) return;
|
|
||||||
|
|
||||||
// Fallback for unmatched routes
|
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
|
||||||
} catch (error) {
|
|
||||||
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
|
||||||
} finally {
|
|
||||||
// Clear request context after request is complete
|
|
||||||
logger.clearRequestContext(requestId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,17 +30,16 @@ import {
|
||||||
sendPotluckError
|
sendPotluckError
|
||||||
} from './middleware.js';
|
} from './middleware.js';
|
||||||
|
|
||||||
import { consumeBonus, getConfig } from './user-data-manager.js';
|
|
||||||
import logger from '../../utils/logger.js';
|
import logger from '../../utils/logger.js';
|
||||||
|
|
||||||
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes, startHealthCheckScheduler, stopHealthCheckScheduler } from './api-routes.js';
|
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件定义
|
* 插件定义
|
||||||
*/
|
*/
|
||||||
const apiPotluckPlugin = {
|
const apiPotluckPlugin = {
|
||||||
name: 'api-potluck',
|
name: 'api-potluck',
|
||||||
version: '1.0.1',
|
version: '1.0.2',
|
||||||
description: 'API 大锅饭 - Key 管理和用量统计插件<br>管理端:<a href="potluck.html" target="_blank">potluck.html</a><br>用户端:<a href="potluck-user.html" target="_blank">potluck-user.html</a>',
|
description: 'API 大锅饭 - Key 管理和用量统计插件<br>管理端:<a href="potluck.html" target="_blank">potluck.html</a><br>用户端:<a href="potluck-user.html" target="_blank">potluck-user.html</a>',
|
||||||
|
|
||||||
// 插件类型:认证插件
|
// 插件类型:认证插件
|
||||||
|
|
@ -55,10 +54,6 @@ const apiPotluckPlugin = {
|
||||||
*/
|
*/
|
||||||
async init(config) {
|
async init(config) {
|
||||||
logger.info('[API Potluck Plugin] Initializing...');
|
logger.info('[API Potluck Plugin] Initializing...');
|
||||||
// 注入配置获取函数
|
|
||||||
setConfigGetter(getConfig);
|
|
||||||
// 启动定时健康检查
|
|
||||||
startHealthCheckScheduler();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,8 +61,6 @@ const apiPotluckPlugin = {
|
||||||
*/
|
*/
|
||||||
async destroy() {
|
async destroy() {
|
||||||
logger.info('[API Potluck Plugin] Destroying...');
|
logger.info('[API Potluck Plugin] Destroying...');
|
||||||
// 停止定时健康检查
|
|
||||||
stopHealthCheckScheduler();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -155,21 +148,24 @@ const apiPotluckPlugin = {
|
||||||
hooks: {
|
hooks: {
|
||||||
/**
|
/**
|
||||||
* 内容生成后钩子 - 记录用量
|
* 内容生成后钩子 - 记录用量
|
||||||
* @param {Object} config - 服务器配置
|
* @param {Object} hookContext - 钩子上下文,包含请求和模型信息
|
||||||
*/
|
*/
|
||||||
async onContentGenerated(config) {
|
async onContentGenerated(hookContext) {
|
||||||
if (config.potluckApiKey) {
|
if (hookContext.potluckApiKey) {
|
||||||
try {
|
try {
|
||||||
// 传入资源包消耗回调
|
// 传入提供商和模型信息
|
||||||
await incrementUsage(config.potluckApiKey, async (apiKey) => {
|
await incrementUsage(
|
||||||
await consumeBonus(apiKey);
|
hookContext.potluckApiKey,
|
||||||
});
|
hookContext.toProvider,
|
||||||
|
hookContext.model
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 静默失败,不影响主流程
|
// 静默失败,不影响主流程
|
||||||
logger.error('[API Potluck Plugin] Failed to record usage:', e.message);
|
logger.error('[API Potluck Plugin] Failed to record usage:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 导出内部函数供外部使用(可选)
|
// 导出内部函数供外部使用(可选)
|
||||||
|
|
@ -186,7 +182,6 @@ const apiPotluckPlugin = {
|
||||||
incrementUsage,
|
incrementUsage,
|
||||||
getStats,
|
getStats,
|
||||||
KEY_PREFIX,
|
KEY_PREFIX,
|
||||||
getConfig,
|
|
||||||
extractPotluckKey,
|
extractPotluckKey,
|
||||||
isPotluckRequest
|
isPotluckRequest
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +203,6 @@ export {
|
||||||
incrementUsage,
|
incrementUsage,
|
||||||
getStats,
|
getStats,
|
||||||
KEY_PREFIX,
|
KEY_PREFIX,
|
||||||
getConfig,
|
|
||||||
extractPotluckKey,
|
extractPotluckKey,
|
||||||
isPotluckRequest
|
isPotluckRequest
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
// 配置常量
|
// 配置文件路径
|
||||||
const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json');
|
const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json');
|
||||||
const KEY_PREFIX = 'maki_';
|
const KEY_PREFIX = 'maki_';
|
||||||
|
|
||||||
// 默认配置(会被 user-data-manager 的配置覆盖)
|
// 默认配置
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
defaultDailyLimit: 500,
|
defaultDailyLimit: 500,
|
||||||
persistInterval: 5000
|
persistInterval: 5000
|
||||||
|
|
@ -56,18 +56,6 @@ function ensureLoaded() {
|
||||||
if (existsSync(KEYS_STORE_FILE)) {
|
if (existsSync(KEYS_STORE_FILE)) {
|
||||||
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
|
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
|
||||||
keyStore = JSON.parse(content);
|
keyStore = JSON.parse(content);
|
||||||
// 兼容历史数据:为旧 Key 添加 bonusRemaining 字段
|
|
||||||
let needsMigration = false;
|
|
||||||
for (const keyData of Object.values(keyStore.keys)) {
|
|
||||||
if (keyData.bonusRemaining === undefined) {
|
|
||||||
keyData.bonusRemaining = 0;
|
|
||||||
needsMigration = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (needsMigration) {
|
|
||||||
logger.info('[API Potluck] Migrated legacy keys: added bonusRemaining field');
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
keyStore = { keys: {} };
|
keyStore = { keys: {} };
|
||||||
syncWriteToFile();
|
syncWriteToFile();
|
||||||
|
|
@ -178,6 +166,7 @@ function checkAndResetDailyCount(keyData) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建新的 API Key
|
* 创建新的 API Key
|
||||||
|
|
||||||
* @param {string} name - Key 名称
|
* @param {string} name - Key 名称
|
||||||
* @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值
|
* @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值
|
||||||
*/
|
*/
|
||||||
|
|
@ -199,8 +188,7 @@ export async function createKey(name = '', dailyLimit = null) {
|
||||||
totalUsage: 0,
|
totalUsage: 0,
|
||||||
lastResetDate: today,
|
lastResetDate: today,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
enabled: true,
|
enabled: true
|
||||||
bonusRemaining: 0 // 剩余资源包总次数(由同步检查更新)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
keyStore.keys[apiKey] = keyData;
|
keyStore.keys[apiKey] = keyData;
|
||||||
|
|
@ -333,7 +321,7 @@ export async function regenerateKey(oldKeyId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 API Key 是否有效且有配额(每日限额 + 资源包)
|
* 验证 API Key 是否有效且有配额
|
||||||
*/
|
*/
|
||||||
export async function validateKey(apiKey) {
|
export async function validateKey(apiKey) {
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
|
|
@ -349,13 +337,7 @@ export async function validateKey(apiKey) {
|
||||||
|
|
||||||
// 检查每日限额
|
// 检查每日限额
|
||||||
if (keyData.todayUsage < keyData.dailyLimit) {
|
if (keyData.todayUsage < keyData.dailyLimit) {
|
||||||
return { valid: true, keyData, useBonus: false };
|
return { valid: true, keyData };
|
||||||
}
|
|
||||||
|
|
||||||
// 每日限额用尽,检查资源包
|
|
||||||
const bonusRemaining = keyData.bonusRemaining || 0;
|
|
||||||
if (bonusRemaining > 0) {
|
|
||||||
return { valid: true, keyData, useBonus: true, bonusRemaining };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: false, reason: 'quota_exceeded', keyData };
|
return { valid: false, reason: 'quota_exceeded', keyData };
|
||||||
|
|
@ -363,47 +345,55 @@ export async function validateKey(apiKey) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 增加 Key 的使用次数(原子操作,直接修改内存)
|
* 增加 Key 的使用次数(原子操作,直接修改内存)
|
||||||
* 优先消耗每日限额,用尽后消耗资源包
|
|
||||||
* @param {string} apiKey - API Key
|
* @param {string} apiKey - API Key
|
||||||
* @param {Function} [onBonusUsed] - 资源包消耗回调,用于更新 data 中的 usedCount
|
* @param {string} provider - 使用的提供商
|
||||||
|
* @param {string} model - 使用的模型
|
||||||
*/
|
*/
|
||||||
export async function incrementUsage(apiKey, onBonusUsed = null) {
|
export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown') {
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
const keyData = keyStore.keys[apiKey];
|
const keyData = keyStore.keys[apiKey];
|
||||||
if (!keyData) return null;
|
if (!keyData) return null;
|
||||||
|
|
||||||
checkAndResetDailyCount(keyData);
|
checkAndResetDailyCount(keyData);
|
||||||
|
|
||||||
let usedBonus = false;
|
// 消耗每日限额
|
||||||
|
|
||||||
// 优先消耗每日限额
|
|
||||||
if (keyData.todayUsage < keyData.dailyLimit) {
|
if (keyData.todayUsage < keyData.dailyLimit) {
|
||||||
keyData.todayUsage += 1;
|
keyData.todayUsage += 1;
|
||||||
} else {
|
} else {
|
||||||
// 每日限额用尽,消耗资源包
|
// 每日限额用尽
|
||||||
const bonusRemaining = keyData.bonusRemaining || 0;
|
return null;
|
||||||
|
|
||||||
if (bonusRemaining > 0) {
|
|
||||||
keyData.bonusRemaining = bonusRemaining - 1;
|
|
||||||
usedBonus = true;
|
|
||||||
|
|
||||||
// 触发回调更新 data 中的 usedCount
|
|
||||||
if (onBonusUsed) {
|
|
||||||
await onBonusUsed(apiKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 无可用配额
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyData.totalUsage += 1;
|
keyData.totalUsage += 1;
|
||||||
keyData.lastUsedAt = new Date().toISOString();
|
keyData.lastUsedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// 记录个人按天统计 (每个 Key 独立)
|
||||||
|
const today = getTodayDateString();
|
||||||
|
if (!keyData.usageHistory) keyData.usageHistory = {};
|
||||||
|
if (!keyData.usageHistory[today]) {
|
||||||
|
keyData.usageHistory[today] = { providers: {}, models: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 provider 和 model 是字符串
|
||||||
|
const pName = String(provider || 'unknown');
|
||||||
|
const mName = String(model || 'unknown');
|
||||||
|
|
||||||
|
const userHistory = keyData.usageHistory[today];
|
||||||
|
userHistory.providers[pName] = (userHistory.providers[pName] || 0) + 1;
|
||||||
|
userHistory.models[mName] = (userHistory.models[mName] || 0) + 1;
|
||||||
|
|
||||||
|
// 清理该 Key 的过期历史 (保留 7 天)
|
||||||
|
const userDates = Object.keys(keyData.usageHistory).sort();
|
||||||
|
if (userDates.length > 7) {
|
||||||
|
const dropDates = userDates.slice(0, userDates.length - 7);
|
||||||
|
dropDates.forEach(d => delete keyData.usageHistory[d]);
|
||||||
|
}
|
||||||
|
|
||||||
markDirty();
|
markDirty();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...keyData,
|
...keyData,
|
||||||
usedBonus
|
usedBonus: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,12 +404,36 @@ export async function getStats() {
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
const keys = Object.values(keyStore.keys);
|
const keys = Object.values(keyStore.keys);
|
||||||
let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0;
|
let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0;
|
||||||
|
const aggregatedHistory = {};
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
checkAndResetDailyCount(key);
|
checkAndResetDailyCount(key);
|
||||||
if (key.enabled) enabledKeys++;
|
if (key.enabled) enabledKeys++;
|
||||||
todayTotalUsage += key.todayUsage;
|
todayTotalUsage += key.todayUsage;
|
||||||
totalUsage += key.totalUsage;
|
totalUsage += key.totalUsage;
|
||||||
|
|
||||||
|
// 汇总每个 Key 的历史数据
|
||||||
|
if (key.usageHistory) {
|
||||||
|
Object.entries(key.usageHistory).forEach(([date, history]) => {
|
||||||
|
if (!aggregatedHistory[date]) {
|
||||||
|
aggregatedHistory[date] = { providers: {}, models: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总提供商
|
||||||
|
if (history.providers) {
|
||||||
|
Object.entries(history.providers).forEach(([p, count]) => {
|
||||||
|
aggregatedHistory[date].providers[p] = (aggregatedHistory[date].providers[p] || 0) + count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总模型
|
||||||
|
if (history.models) {
|
||||||
|
Object.entries(history.models).forEach(([m, count]) => {
|
||||||
|
aggregatedHistory[date].models[m] = (aggregatedHistory[date].models[m] || 0) + count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -427,48 +441,11 @@ export async function getStats() {
|
||||||
enabledKeys,
|
enabledKeys,
|
||||||
disabledKeys: keys.length - enabledKeys,
|
disabledKeys: keys.length - enabledKeys,
|
||||||
todayTotalUsage,
|
todayTotalUsage,
|
||||||
totalUsage
|
totalUsage,
|
||||||
|
usageHistory: aggregatedHistory
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 凭证资源包管理 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 Key 的剩余资源包次数(由同步检查调用)
|
|
||||||
* @param {string} keyId - Key ID
|
|
||||||
* @param {number} bonusRemaining - 剩余资源包总次数
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function updateBonusRemaining(keyId, bonusRemaining) {
|
|
||||||
ensureLoaded();
|
|
||||||
const keyData = keyStore.keys[keyId];
|
|
||||||
if (!keyData) return false;
|
|
||||||
|
|
||||||
keyData.bonusRemaining = Math.max(0, bonusRemaining);
|
|
||||||
markDirty();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Key 的资源包信息
|
|
||||||
* @param {string} keyId - Key ID
|
|
||||||
* @param {Function} getConfigFn - 获取配置的函数(从 user-data-manager 传入)
|
|
||||||
* @returns {Promise<Object|null>}
|
|
||||||
*/
|
|
||||||
export async function getBonusInfo(keyId, getConfigFn = null) {
|
|
||||||
ensureLoaded();
|
|
||||||
const keyData = keyStore.keys[keyId];
|
|
||||||
if (!keyData) return null;
|
|
||||||
|
|
||||||
// 从 user-data-manager 获取配置
|
|
||||||
const config = getConfigFn ? getConfigFn() : { bonusPerCredential: 300, bonusValidityDays: 30 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
bonusRemaining: keyData.bonusRemaining || 0,
|
|
||||||
bonusPerCredential: config.bonusPerCredential,
|
|
||||||
validityDays: config.bonusValidityDays
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量更新所有 Key 的每日限额
|
* 批量更新所有 Key 的每日限额
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
|
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
|
||||||
|
import logger from '../../utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中提取 Potluck API Key
|
* 从请求中提取 Potluck API Key
|
||||||
|
|
|
||||||
|
|
@ -1,722 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* API 大锅饭 - 用户数据管理模块
|
* API 大锅饭 - 用户数据管理模块
|
||||||
* 管理用户关联的凭据文件路径和资源包
|
* 注意:此模块已禁用,所有凭证管理和资源包相关功能已移除
|
||||||
* 使用 Mutex 解决并发问题
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fs } from 'fs';
|
// 空模块,保留文件以避免导入错误
|
||||||
import logger from '../../utils/logger.js';
|
|
||||||
import { existsSync, readFileSync, writeFileSync, watch } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// 配置文件路径
|
|
||||||
const USER_DATA_FILE = path.join(process.cwd(), 'configs', 'api-potluck-data.json');
|
|
||||||
|
|
||||||
// 默认配置值
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
defaultDailyLimit: 500,
|
|
||||||
bonusPerCredential: 300,
|
|
||||||
bonusValidityDays: 30,
|
|
||||||
persistInterval: 5000
|
|
||||||
};
|
|
||||||
|
|
||||||
// 内存缓存
|
|
||||||
let userDataStore = null;
|
|
||||||
let isDirty = false;
|
|
||||||
let isWriting = false;
|
|
||||||
let persistTimer = null;
|
|
||||||
let fileWatcher = null;
|
|
||||||
let currentPersistInterval = DEFAULT_CONFIG.persistInterval;
|
|
||||||
|
|
||||||
// ============ 简易 Mutex 实现 ============
|
|
||||||
class SimpleMutex {
|
|
||||||
constructor() {
|
|
||||||
this._locked = false;
|
|
||||||
this._waiting = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async acquire() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this._locked) {
|
|
||||||
this._locked = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this._waiting.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
release() {
|
|
||||||
if (this._waiting.length > 0) {
|
|
||||||
const next = this._waiting.shift();
|
|
||||||
next();
|
|
||||||
} else {
|
|
||||||
this._locked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runExclusive(fn) {
|
|
||||||
await this.acquire();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全局锁:用于资源包消耗操作
|
|
||||||
const bonusMutex = new SimpleMutex();
|
|
||||||
|
|
||||||
// ============ 配置管理 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取完整配置(支持热更新)
|
|
||||||
*/
|
|
||||||
function getFullConfig() {
|
|
||||||
ensureLoaded();
|
|
||||||
const config = userDataStore.config || {};
|
|
||||||
return {
|
|
||||||
defaultDailyLimit: config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit,
|
|
||||||
bonusPerCredential: config.bonusPerCredential ?? DEFAULT_CONFIG.bonusPerCredential,
|
|
||||||
bonusValidityDays: config.bonusValidityDays ?? DEFAULT_CONFIG.bonusValidityDays,
|
|
||||||
persistInterval: config.persistInterval ?? DEFAULT_CONFIG.persistInterval
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取资源包配置(兼容旧接口)
|
|
||||||
*/
|
|
||||||
function getBonusConfig() {
|
|
||||||
const config = getFullConfig();
|
|
||||||
return {
|
|
||||||
bonusPerCredential: config.bonusPerCredential,
|
|
||||||
bonusValidityDays: config.bonusValidityDays
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新配置
|
|
||||||
* @param {Object} newConfig - 新配置
|
|
||||||
* @returns {Object} 更新后的完整配置
|
|
||||||
*/
|
|
||||||
export async function updateConfig(newConfig) {
|
|
||||||
ensureLoaded();
|
|
||||||
|
|
||||||
if (!userDataStore.config) {
|
|
||||||
userDataStore.config = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证并更新各配置项
|
|
||||||
if (typeof newConfig.defaultDailyLimit === 'number' && newConfig.defaultDailyLimit > 0) {
|
|
||||||
userDataStore.config.defaultDailyLimit = newConfig.defaultDailyLimit;
|
|
||||||
}
|
|
||||||
if (typeof newConfig.bonusPerCredential === 'number' && newConfig.bonusPerCredential >= 0) {
|
|
||||||
userDataStore.config.bonusPerCredential = newConfig.bonusPerCredential;
|
|
||||||
}
|
|
||||||
if (typeof newConfig.bonusValidityDays === 'number' && newConfig.bonusValidityDays > 0) {
|
|
||||||
userDataStore.config.bonusValidityDays = newConfig.bonusValidityDays;
|
|
||||||
}
|
|
||||||
if (typeof newConfig.persistInterval === 'number' && newConfig.persistInterval >= 1000) {
|
|
||||||
userDataStore.config.persistInterval = newConfig.persistInterval;
|
|
||||||
// 更新持久化定时器
|
|
||||||
updatePersistTimer(newConfig.persistInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
markDirty();
|
|
||||||
await persistIfDirty();
|
|
||||||
|
|
||||||
const updatedConfig = getFullConfig();
|
|
||||||
logger.info(`[API Potluck UserData] Config updated:`, updatedConfig);
|
|
||||||
return updatedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新持久化定时器间隔
|
|
||||||
*/
|
|
||||||
function updatePersistTimer(newInterval) {
|
|
||||||
if (newInterval === currentPersistInterval) return;
|
|
||||||
|
|
||||||
currentPersistInterval = newInterval;
|
|
||||||
if (persistTimer) {
|
|
||||||
clearInterval(persistTimer);
|
|
||||||
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
|
|
||||||
logger.info(`[API Potluck UserData] Persist interval updated to ${currentPersistInterval}ms`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前配置(对外暴露)
|
|
||||||
*/
|
|
||||||
export function getConfig() {
|
|
||||||
return getFullConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 兼容旧接口:更新资源包配置
|
|
||||||
*/
|
|
||||||
export async function updateBonusConfig(newConfig) {
|
|
||||||
return updateConfig(newConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化:从文件加载数据到内存
|
|
||||||
*/
|
|
||||||
function ensureLoaded() {
|
|
||||||
if (userDataStore !== null) return;
|
|
||||||
try {
|
|
||||||
if (existsSync(USER_DATA_FILE)) {
|
|
||||||
const content = readFileSync(USER_DATA_FILE, 'utf8');
|
|
||||||
userDataStore = JSON.parse(content);
|
|
||||||
// 兼容旧数据:确保 config 和 users 存在
|
|
||||||
if (!userDataStore.config) {
|
|
||||||
userDataStore.config = {};
|
|
||||||
}
|
|
||||||
if (!userDataStore.users) {
|
|
||||||
userDataStore.users = {};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
userDataStore = { config: {}, users: {} };
|
|
||||||
syncWriteToFile();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[API Potluck UserData] Failed to load user data:', error.message);
|
|
||||||
userDataStore = { config: {}, users: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取配置的持久化间隔
|
|
||||||
const config = userDataStore.config || {};
|
|
||||||
currentPersistInterval = config.persistInterval ?? DEFAULT_CONFIG.persistInterval;
|
|
||||||
|
|
||||||
// 启动定期持久化
|
|
||||||
if (!persistTimer) {
|
|
||||||
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
|
|
||||||
}
|
|
||||||
// 启动文件监听(热更新)
|
|
||||||
startFileWatcher();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步写入文件(仅初始化时使用)
|
|
||||||
*/
|
|
||||||
function syncWriteToFile() {
|
|
||||||
try {
|
|
||||||
const dir = path.dirname(USER_DATA_FILE);
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
require('fs').mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
writeFileSync(USER_DATA_FILE, JSON.stringify(userDataStore, null, 2), 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[API Potluck UserData] Sync write failed:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 异步持久化(带写锁)
|
|
||||||
*/
|
|
||||||
async function persistIfDirty() {
|
|
||||||
if (!isDirty || isWriting || userDataStore === null) return;
|
|
||||||
isWriting = true;
|
|
||||||
try {
|
|
||||||
const dir = path.dirname(USER_DATA_FILE);
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
const tempFile = USER_DATA_FILE + '.tmp';
|
|
||||||
await fs.writeFile(tempFile, JSON.stringify(userDataStore, null, 2), 'utf8');
|
|
||||||
await fs.rename(tempFile, USER_DATA_FILE);
|
|
||||||
isDirty = false;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[API Potluck UserData] Persist failed:', error.message);
|
|
||||||
} finally {
|
|
||||||
isWriting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标记数据已修改
|
|
||||||
*/
|
|
||||||
function markDirty() {
|
|
||||||
isDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动文件监听(热更新配置)
|
|
||||||
*/
|
|
||||||
let lastReloadTime = 0;
|
|
||||||
function startFileWatcher() {
|
|
||||||
if (fileWatcher) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
fileWatcher = watch(USER_DATA_FILE, { persistent: false }, (eventType) => {
|
|
||||||
if (eventType !== 'change') return;
|
|
||||||
|
|
||||||
// 防抖:忽略自己写入触发的事件
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastReloadTime < 1000 || isWriting) return;
|
|
||||||
lastReloadTime = now;
|
|
||||||
|
|
||||||
// 重新加载配置部分
|
|
||||||
try {
|
|
||||||
const content = readFileSync(USER_DATA_FILE, 'utf8');
|
|
||||||
const newData = JSON.parse(content);
|
|
||||||
|
|
||||||
// 只热更新 config 部分,不覆盖内存中的 users 数据
|
|
||||||
if (newData.config) {
|
|
||||||
const oldConfig = userDataStore.config || {};
|
|
||||||
const newConfig = newData.config;
|
|
||||||
|
|
||||||
// 检查配置是否有变化
|
|
||||||
if (JSON.stringify(oldConfig) !== JSON.stringify(newConfig)) {
|
|
||||||
userDataStore.config = newConfig;
|
|
||||||
logger.info('[API Potluck UserData] Config hot-reloaded:', getBonusConfig());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[API Potluck UserData] Hot-reload failed:', error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('[API Potluck UserData] File watcher started for config hot-reload');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[API Potluck UserData] Failed to start file watcher:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止文件监听
|
|
||||||
*/
|
|
||||||
export function stopFileWatcher() {
|
|
||||||
if (fileWatcher) {
|
|
||||||
fileWatcher.close();
|
|
||||||
fileWatcher = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户数据
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @returns {Object|null}
|
|
||||||
*/
|
|
||||||
export function getUserData(apiKey) {
|
|
||||||
ensureLoaded();
|
|
||||||
return userDataStore.users[apiKey] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化用户数据(如果不存在)
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
export function ensureUserData(apiKey) {
|
|
||||||
ensureLoaded();
|
|
||||||
if (!userDataStore.users[apiKey]) {
|
|
||||||
userDataStore.users[apiKey] = {
|
|
||||||
credentials: [],
|
|
||||||
credentialBonuses: [],
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
// 兼容旧数据:添加 credentialBonuses 数组
|
|
||||||
if (!userDataStore.users[apiKey].credentialBonuses) {
|
|
||||||
userDataStore.users[apiKey].credentialBonuses = [];
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
return userDataStore.users[apiKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加凭据路径到用户
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {Object} credentialInfo - 凭据信息
|
|
||||||
* @param {string} credentialInfo.path - 凭据文件路径
|
|
||||||
* @param {string} credentialInfo.provider - 提供商类型 (如 'claude-kiro-oauth')
|
|
||||||
* @param {string} [credentialInfo.authMethod] - 认证方式 (如 'builder-id', 'google', 'github')
|
|
||||||
* @returns {Object} 添加的凭据信息
|
|
||||||
*/
|
|
||||||
export async function addUserCredential(apiKey, credentialInfo) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = ensureUserData(apiKey);
|
|
||||||
|
|
||||||
// 检查是否已存在相同路径
|
|
||||||
const existingIndex = userData.credentials.findIndex(c => c.path === credentialInfo.path);
|
|
||||||
|
|
||||||
// 只保留核心字段,健康状态从主服务实时获取
|
|
||||||
const credential = {
|
|
||||||
id: `cred_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
|
||||||
path: credentialInfo.path,
|
|
||||||
provider: credentialInfo.provider || 'claude-kiro-oauth',
|
|
||||||
authMethod: credentialInfo.authMethod || 'unknown',
|
|
||||||
addedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// 更新已存在的凭据,保留原有 id 和 addedAt
|
|
||||||
credential.id = userData.credentials[existingIndex].id;
|
|
||||||
credential.addedAt = userData.credentials[existingIndex].addedAt;
|
|
||||||
userData.credentials[existingIndex] = credential;
|
|
||||||
} else {
|
|
||||||
userData.credentials.push(credential);
|
|
||||||
}
|
|
||||||
|
|
||||||
markDirty();
|
|
||||||
await persistIfDirty();
|
|
||||||
|
|
||||||
return credential;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除用户凭据
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {string} credentialId - 凭据 ID
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export async function removeUserCredential(apiKey, credentialId) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData) return false;
|
|
||||||
|
|
||||||
const index = userData.credentials.findIndex(c => c.id === credentialId);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
userData.credentials.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
await persistIfDirty();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户的所有凭据
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
export function getUserCredentials(apiKey) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
return userData ? userData.credentials : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过路径查找凭据
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {string} credPath - 凭据文件路径
|
|
||||||
* @returns {Object|null}
|
|
||||||
*/
|
|
||||||
export function findCredentialByPath(apiKey, credPath) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData) return null;
|
|
||||||
|
|
||||||
return userData.credentials.find(c => c.path === credPath) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查凭据路径是否已被任何用户使用
|
|
||||||
* @param {string} credPath - 凭据文件路径
|
|
||||||
* @returns {{exists: boolean, apiKey?: string}}
|
|
||||||
*/
|
|
||||||
export function isCredentialPathUsed(credPath) {
|
|
||||||
ensureLoaded();
|
|
||||||
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
|
|
||||||
const found = userData.credentials.find(c => c.path === credPath);
|
|
||||||
if (found) {
|
|
||||||
return { exists: true, apiKey };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { exists: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 迁移用户凭据到新 Key(用于 Key 重置时)
|
|
||||||
* @param {string} oldApiKey - 旧 API Key
|
|
||||||
* @param {string} newApiKey - 新 API Key
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function migrateUserCredentials(oldApiKey, newApiKey) {
|
|
||||||
ensureLoaded();
|
|
||||||
const oldUserData = userDataStore.users[oldApiKey];
|
|
||||||
if (!oldUserData) return false;
|
|
||||||
|
|
||||||
// 将旧用户数据迁移到新 Key
|
|
||||||
userDataStore.users[newApiKey] = {
|
|
||||||
...oldUserData,
|
|
||||||
migratedFrom: oldApiKey.substring(0, 12) + '...',
|
|
||||||
migratedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除旧用户数据
|
|
||||||
delete userDataStore.users[oldApiKey];
|
|
||||||
|
|
||||||
markDirty();
|
|
||||||
await persistIfDirty();
|
|
||||||
|
|
||||||
logger.info(`[API Potluck UserData] Migrated credentials from ${oldApiKey.substring(0, 12)}... to ${newApiKey.substring(0, 12)}...`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有用户及其凭据(用于批量健康检查)
|
|
||||||
* @returns {Array<{apiKey: string, credentials: Array}>}
|
|
||||||
*/
|
|
||||||
export function getAllUsersCredentials() {
|
|
||||||
ensureLoaded();
|
|
||||||
const result = [];
|
|
||||||
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
|
|
||||||
if (userData.credentials && userData.credentials.length > 0) {
|
|
||||||
result.push({
|
|
||||||
apiKey,
|
|
||||||
credentials: userData.credentials
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 凭证资源包管理 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算资源包过期时间(使用动态配置)
|
|
||||||
* @param {string} grantedAt - 授予时间
|
|
||||||
* @returns {Date}
|
|
||||||
*/
|
|
||||||
function calculateExpiresAt(grantedAt) {
|
|
||||||
const { bonusValidityDays } = getBonusConfig();
|
|
||||||
const granted = new Date(grantedAt);
|
|
||||||
return new Date(granted.getTime() + bonusValidityDays * 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查资源包是否过期
|
|
||||||
* @param {Object} bonus - 资源包对象
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function isBonusExpired(bonus) {
|
|
||||||
const expiresAt = calculateExpiresAt(bonus.grantedAt);
|
|
||||||
return new Date() > expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为凭证添加资源包(凭证健康时调用)
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {string} credentialId - 凭证 ID
|
|
||||||
* @returns {Object|null} 添加的资源包信息
|
|
||||||
*/
|
|
||||||
export async function addCredentialBonus(apiKey, credentialId) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = ensureUserData(apiKey);
|
|
||||||
|
|
||||||
// 检查是否已存在
|
|
||||||
const existing = userData.credentialBonuses.find(b => b.credentialId === credentialId);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bonus = {
|
|
||||||
credentialId,
|
|
||||||
grantedAt: new Date().toISOString(),
|
|
||||||
usedCount: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
userData.credentialBonuses.push(bonus);
|
|
||||||
markDirty();
|
|
||||||
|
|
||||||
logger.info(`[API Potluck UserData] Added bonus for credential: ${credentialId}`);
|
|
||||||
return bonus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除凭证资源包(凭证失效时调用)
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {string} credentialId - 凭证 ID
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export async function removeCredentialBonus(apiKey, credentialId) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData || !userData.credentialBonuses) return false;
|
|
||||||
|
|
||||||
const index = userData.credentialBonuses.findIndex(b => b.credentialId === credentialId);
|
|
||||||
if (index === -1) return false;
|
|
||||||
|
|
||||||
userData.credentialBonuses.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
|
|
||||||
logger.info(`[API Potluck UserData] Removed bonus for credential: ${credentialId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消耗资源包次数(FIFO 顺序,使用 Mutex 保证并发安全)
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @returns {boolean} 是否成功消耗
|
|
||||||
*/
|
|
||||||
export async function consumeBonus(apiKey) {
|
|
||||||
// 使用 Mutex 保证并发安全
|
|
||||||
return bonusMutex.runExclusive(async () => {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData || !userData.credentialBonuses) return false;
|
|
||||||
|
|
||||||
const { bonusPerCredential } = getBonusConfig();
|
|
||||||
|
|
||||||
// 按 grantedAt 排序(FIFO)
|
|
||||||
const sortedBonuses = userData.credentialBonuses
|
|
||||||
.filter(b => !isBonusExpired(b))
|
|
||||||
.sort((a, b) => new Date(a.grantedAt) - new Date(b.grantedAt));
|
|
||||||
|
|
||||||
// 找到第一个有剩余次数的资源包
|
|
||||||
for (const bonus of sortedBonuses) {
|
|
||||||
const remaining = bonusPerCredential - bonus.usedCount;
|
|
||||||
if (remaining > 0) {
|
|
||||||
bonus.usedCount += 1;
|
|
||||||
markDirty();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算用户的剩余资源包总次数
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {Set<string>} [healthyCredentialIds] - 健康凭证 ID 集合(可选,用于过滤)
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
export function calculateBonusRemaining(apiKey, healthyCredentialIds = null) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData || !userData.credentialBonuses) return 0;
|
|
||||||
|
|
||||||
const { bonusPerCredential } = getBonusConfig();
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
for (const bonus of userData.credentialBonuses) {
|
|
||||||
// 检查是否过期
|
|
||||||
if (isBonusExpired(bonus)) continue;
|
|
||||||
|
|
||||||
// 如果提供了健康凭证集合,检查凭证是否健康
|
|
||||||
if (healthyCredentialIds && !healthyCredentialIds.has(bonus.credentialId)) continue;
|
|
||||||
|
|
||||||
const remaining = bonusPerCredential - bonus.usedCount;
|
|
||||||
if (remaining > 0) {
|
|
||||||
total += remaining;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步资源包状态(根据健康凭证列表)
|
|
||||||
* 兼容历史数据:为已有健康凭证创建资源包,使用凭证的 addedAt 作为 grantedAt
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @param {Array<{id: string, isHealthy: boolean, addedAt?: string}>} credentialsWithHealth - 带健康状态的凭证列表
|
|
||||||
* @returns {{added: number, removed: number, bonusRemaining: number}}
|
|
||||||
*/
|
|
||||||
export async function syncCredentialBonuses(apiKey, credentialsWithHealth) {
|
|
||||||
ensureLoaded();
|
|
||||||
const userData = ensureUserData(apiKey);
|
|
||||||
|
|
||||||
let added = 0, removed = 0;
|
|
||||||
|
|
||||||
// 获取健康凭证 ID 集合
|
|
||||||
const healthyIds = new Set(
|
|
||||||
credentialsWithHealth
|
|
||||||
.filter(c => c.isHealthy === true)
|
|
||||||
.map(c => c.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 为新的健康凭证添加资源包
|
|
||||||
for (const cred of credentialsWithHealth) {
|
|
||||||
if (cred.isHealthy !== true) continue;
|
|
||||||
|
|
||||||
const exists = userData.credentialBonuses.some(b => b.credentialId === cred.id);
|
|
||||||
if (!exists) {
|
|
||||||
// 使用凭证的 addedAt 作为资源包授予时间(兼容历史数据)
|
|
||||||
const grantedAt = cred.addedAt || new Date().toISOString();
|
|
||||||
userData.credentialBonuses.push({
|
|
||||||
credentialId: cred.id,
|
|
||||||
grantedAt: grantedAt,
|
|
||||||
usedCount: 0
|
|
||||||
});
|
|
||||||
added++;
|
|
||||||
logger.info(`[API Potluck UserData] Created bonus for credential ${cred.id}, grantedAt: ${grantedAt}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除失效凭证的资源包
|
|
||||||
const toRemove = userData.credentialBonuses.filter(b => !healthyIds.has(b.credentialId));
|
|
||||||
for (const bonus of toRemove) {
|
|
||||||
const idx = userData.credentialBonuses.indexOf(bonus);
|
|
||||||
if (idx !== -1) {
|
|
||||||
userData.credentialBonuses.splice(idx, 1);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理过期资源包
|
|
||||||
const expiredCount = userData.credentialBonuses.filter(b => isBonusExpired(b)).length;
|
|
||||||
userData.credentialBonuses = userData.credentialBonuses.filter(b => !isBonusExpired(b));
|
|
||||||
|
|
||||||
if (added > 0 || removed > 0 || expiredCount > 0) {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算剩余资源包次数
|
|
||||||
const bonusRemaining = calculateBonusRemaining(apiKey, healthyIds);
|
|
||||||
|
|
||||||
return { added, removed, bonusRemaining };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户的资源包详情
|
|
||||||
* @param {string} apiKey - 用户的 API Key
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
export function getBonusDetails(apiKey) {
|
|
||||||
ensureLoaded();
|
|
||||||
const { bonusPerCredential, bonusValidityDays } = getBonusConfig();
|
|
||||||
const userData = userDataStore.users[apiKey];
|
|
||||||
if (!userData) {
|
|
||||||
return {
|
|
||||||
bonuses: [],
|
|
||||||
totalRemaining: 0,
|
|
||||||
bonusPerCredential,
|
|
||||||
validityDays: bonusValidityDays
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const bonuses = (userData.credentialBonuses || [])
|
|
||||||
.filter(b => !isBonusExpired(b))
|
|
||||||
.map(b => ({
|
|
||||||
credentialId: b.credentialId,
|
|
||||||
grantedAt: b.grantedAt,
|
|
||||||
expiresAt: calculateExpiresAt(b.grantedAt).toISOString(),
|
|
||||||
usedCount: b.usedCount,
|
|
||||||
remaining: bonusPerCredential - b.usedCount
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalRemaining = bonuses.reduce((sum, b) => sum + Math.max(0, b.remaining), 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
bonuses,
|
|
||||||
totalRemaining,
|
|
||||||
bonusPerCredential,
|
|
||||||
validityDays: bonusValidityDays
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有用户的 API Key 列表
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getAllUserApiKeys() {
|
|
||||||
ensureLoaded();
|
|
||||||
return Object.keys(userDataStore.users);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { USER_DATA_FILE };
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { CONFIG } from '../core/config-manager.js';
|
import { CONFIG } from '../core/config-manager.js';
|
||||||
|
import { getClientIp } from '../utils/common.js';
|
||||||
|
|
||||||
// Token存储到本地文件中
|
// Token存储到本地文件中
|
||||||
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
||||||
|
|
@ -163,6 +164,84 @@ async function deleteToken(token) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理登录尝试频率和锁定
|
||||||
|
*/
|
||||||
|
class LoginAttemptManager {
|
||||||
|
constructor() {
|
||||||
|
this.attempts = new Map(); // IP -> { count, lastAttempt, lockoutUntil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 IP 的状态
|
||||||
|
*/
|
||||||
|
getIpStatus(ip) {
|
||||||
|
if (!this.attempts.has(ip)) {
|
||||||
|
this.attempts.set(ip, { count: 0, lastAttempt: 0, lockoutUntil: 0 });
|
||||||
|
}
|
||||||
|
return this.attempts.get(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否被锁定
|
||||||
|
*/
|
||||||
|
isLockedOut(ip) {
|
||||||
|
const status = this.getIpStatus(ip);
|
||||||
|
if (status.lockoutUntil > Date.now()) {
|
||||||
|
return {
|
||||||
|
locked: true,
|
||||||
|
remainingTime: Math.ceil((status.lockoutUntil - Date.now()) / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 如果锁定时间已过,重置失败次数
|
||||||
|
if (status.lockoutUntil > 0 && status.lockoutUntil <= Date.now()) {
|
||||||
|
status.count = 0;
|
||||||
|
status.lockoutUntil = 0;
|
||||||
|
}
|
||||||
|
return { locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否请求过于频繁
|
||||||
|
*/
|
||||||
|
isTooFrequent(ip) {
|
||||||
|
const status = this.getIpStatus(ip);
|
||||||
|
const minInterval = CONFIG.LOGIN_MIN_INTERVAL || 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - status.lastAttempt < minInterval) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
status.lastAttempt = now;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次失败
|
||||||
|
*/
|
||||||
|
recordFailure(ip) {
|
||||||
|
const status = this.getIpStatus(ip);
|
||||||
|
status.count++;
|
||||||
|
const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5;
|
||||||
|
const lockoutDuration = (CONFIG.LOGIN_LOCKOUT_DURATION || 1800) * 1000;
|
||||||
|
|
||||||
|
if (status.count >= maxAttempts) {
|
||||||
|
status.lockoutUntil = Date.now() + lockoutDuration;
|
||||||
|
logger.warn(`[Auth] IP ${ip} locked out due to too many failed login attempts (${status.count})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功后重置
|
||||||
|
*/
|
||||||
|
reset(ip) {
|
||||||
|
this.attempts.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginAttemptManager = new LoginAttemptManager();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理过期的token
|
* 清理过期的token
|
||||||
*/
|
*/
|
||||||
|
|
@ -205,7 +284,39 @@ export async function checkAuth(req) {
|
||||||
export async function handleLoginRequest(req, res) {
|
export async function handleLoginRequest(req, res) {
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' }));
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Only POST requests are supported',
|
||||||
|
messageCode: 'login.error.postOnly'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
|
||||||
|
// 1. 检查锁定状态
|
||||||
|
const lockout = loginAttemptManager.isLockedOut(ip);
|
||||||
|
if (lockout.locked) {
|
||||||
|
logger.warn(`[Auth] Login attempt from locked IP: ${ip}, reason: account_locked, remaining: ${lockout.remainingTime}s`);
|
||||||
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: `Account temporarily locked due to too many failed attempts. Please try again in ${lockout.remainingTime} seconds.`,
|
||||||
|
messageCode: 'login.error.locked',
|
||||||
|
messageParams: { time: lockout.remainingTime }
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 频率限制
|
||||||
|
if (loginAttemptManager.isTooFrequent(ip)) {
|
||||||
|
logger.warn(`[Auth] Login attempt too frequent from IP: ${ip}, reason: rate_limit`);
|
||||||
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Too many requests, please slow down.',
|
||||||
|
messageCode: 'login.error.tooFrequent'
|
||||||
|
}));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,17 +325,27 @@ export async function handleLoginRequest(req, res) {
|
||||||
const { password } = requestData;
|
const { password } = requestData;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
logger.warn(`[Auth] Login failed from IP: ${ip}, reason: empty_password`);
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' }));
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Password cannot be empty',
|
||||||
|
messageCode: 'login.error.empty'
|
||||||
|
}));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await validateCredentials(password);
|
const isValid = await validateCredentials(password);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
|
logger.info(`[Auth] Login successful from IP: ${ip}`);
|
||||||
|
// 登录成功,重置计数
|
||||||
|
loginAttemptManager.reset(ip);
|
||||||
|
|
||||||
// Generate simple token
|
// Generate simple token
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
const expiryTime = getExpiryTime();
|
const loginExpiry = CONFIG.LOGIN_EXPIRY || 3600;
|
||||||
|
const expiryTime = Date.now() + (loginExpiry * 1000);
|
||||||
|
|
||||||
// Store token info to local file
|
// Store token info to local file
|
||||||
await saveToken(token, {
|
await saveToken(token, {
|
||||||
|
|
@ -238,21 +359,37 @@ export async function handleLoginRequest(req, res) {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
token,
|
token,
|
||||||
expiresIn: `${CONFIG.LOGIN_EXPIRY || 3600} seconds`
|
expiresIn: `${loginExpiry} seconds`
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
|
// 登录失败,记录
|
||||||
|
const isLocked = loginAttemptManager.recordFailure(ip);
|
||||||
|
const status = loginAttemptManager.getIpStatus(ip);
|
||||||
|
const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5;
|
||||||
|
const remaining = maxAttempts - status.count;
|
||||||
|
const lockoutDuration = CONFIG.LOGIN_LOCKOUT_DURATION || 1800;
|
||||||
|
|
||||||
|
logger.warn(`[Auth] Login failed from IP: ${ip}, reason: incorrect_password, remaining_attempts: ${Math.max(0, remaining)}${isLocked ? ', result: locked' : ''}`);
|
||||||
|
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Incorrect password, please try again'
|
message: isLocked
|
||||||
|
? `Incorrect password. Account locked for ${Math.ceil(lockoutDuration / 60)} minutes.`
|
||||||
|
: `Incorrect password. ${remaining} attempts remaining.`,
|
||||||
|
messageCode: isLocked ? 'login.error.incorrectWithLock' : 'login.error.incorrectWithRemaining',
|
||||||
|
messageParams: isLocked ? { time: Math.ceil(lockoutDuration / 60) } : { count: remaining }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Auth] Login processing error:', error);
|
logger.error('[Auth] Login processing error:', error);
|
||||||
|
const isJsonError = error.message === 'Invalid JSON format';
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || 'Server error'
|
message: error.message || 'Server error',
|
||||||
|
messageCode: isJsonError ? 'login.error.invalidJson' : undefined
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -77,51 +77,96 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) {
|
||||||
|
|
||||||
// 读取文件内容进行分析
|
// 读取文件内容进行分析
|
||||||
let content = '';
|
let content = '';
|
||||||
let type = 'oauth_credentials';
|
let type = 'oauth'; // 默认为 oauth 类型
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let oauthProvider = 'unknown';
|
let oauthProvider = 'unknown';
|
||||||
let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig);
|
let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig);
|
||||||
|
|
||||||
|
// 从路径预检测提供商
|
||||||
|
const normalizedPath = relativePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (normalizedPath.includes('/kiro/')) oauthProvider = 'kiro';
|
||||||
|
else if (normalizedPath.includes('/gemini/')) oauthProvider = 'gemini';
|
||||||
|
else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen';
|
||||||
|
else if (normalizedPath.includes('/antigravity/')) oauthProvider = 'antigravity';
|
||||||
|
else if (normalizedPath.includes('/codex/')) oauthProvider = 'codex';
|
||||||
|
else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
content = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
||||||
|
// 1. 首先尝试根据文件名识别特定类型的配置 (最高优先级)
|
||||||
|
const lowerFilename = filename.toLowerCase();
|
||||||
|
if (lowerFilename === 'provider_pools.json' || lowerFilename === 'provider-pools.json') {
|
||||||
|
type = 'provider-pool';
|
||||||
|
} else if (lowerFilename.includes('system_prompt') || lowerFilename.includes('system-prompt')) {
|
||||||
|
type = 'system-prompt';
|
||||||
|
} else if (lowerFilename === 'plugins.json') {
|
||||||
|
type = 'plugins';
|
||||||
|
} else if (lowerFilename === 'usage-cache.json') {
|
||||||
|
type = 'usage';
|
||||||
|
} else if (lowerFilename === 'config.json') {
|
||||||
|
type = 'config';
|
||||||
|
} else if (lowerFilename.includes('potluck-keys')) {
|
||||||
|
type = 'api-key';
|
||||||
|
} else if (lowerFilename.includes('potluck-data')) {
|
||||||
|
type = 'database';
|
||||||
|
} else if (lowerFilename === 'token-store.json') {
|
||||||
|
type = 'oauth';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 根据内容进一步识别和完善信息
|
||||||
if (ext === '.json') {
|
if (ext === '.json') {
|
||||||
const rawContent = await fs.readFile(filePath, 'utf8');
|
try {
|
||||||
const jsonData = JSON.parse(rawContent);
|
const jsonData = JSON.parse(content);
|
||||||
content = rawContent;
|
|
||||||
|
// 如果文件名没识别出类型,尝试从内容识别
|
||||||
// 识别OAuth提供商
|
if (type === 'oauth') {
|
||||||
if (jsonData.apiKey || jsonData.api_key) {
|
if (jsonData.providerPools || jsonData.provider_pools) {
|
||||||
type = 'api_key';
|
type = 'provider-pool';
|
||||||
} else if (jsonData.client_id || jsonData.client_secret) {
|
} else if (jsonData.apiKey || jsonData.api_key) {
|
||||||
oauthProvider = 'oauth2';
|
type = 'api-key';
|
||||||
} else if (jsonData.access_token || jsonData.refresh_token) {
|
}
|
||||||
oauthProvider = 'token_based';
|
|
||||||
} else if (jsonData.credentials) {
|
|
||||||
oauthProvider = 'service_account';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonData.base_url || jsonData.endpoint) {
|
|
||||||
if (jsonData.base_url.includes('openai.com')) {
|
|
||||||
oauthProvider = 'openai';
|
|
||||||
} else if (jsonData.base_url.includes('anthropic.com')) {
|
|
||||||
oauthProvider = 'claude';
|
|
||||||
} else if (jsonData.base_url.includes('googleapis.com')) {
|
|
||||||
oauthProvider = 'gemini';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 识别具体的提供商/认证方式
|
||||||
|
if (jsonData.client_id || jsonData.client_secret) {
|
||||||
|
if (oauthProvider === 'unknown') oauthProvider = 'oauth2';
|
||||||
|
} else if (jsonData.access_token || jsonData.refresh_token) {
|
||||||
|
if (oauthProvider === 'unknown') oauthProvider = 'token_based';
|
||||||
|
} else if (jsonData.credentials) {
|
||||||
|
if (oauthProvider === 'unknown') oauthProvider = 'service_account';
|
||||||
|
} else if (jsonData.apiKey || jsonData.api_key) {
|
||||||
|
if (oauthProvider === 'unknown') oauthProvider = 'api_key';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.base_url || jsonData.endpoint) {
|
||||||
|
const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase();
|
||||||
|
if (baseUrl.includes('openai.com')) {
|
||||||
|
oauthProvider = 'openai';
|
||||||
|
} else if (baseUrl.includes('anthropic.com')) {
|
||||||
|
oauthProvider = 'claude';
|
||||||
|
} else if (baseUrl.includes('googleapis.com')) {
|
||||||
|
oauthProvider = 'gemini';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (jsonErr) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = `JSON Parse Error: ${jsonErr.message}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = await fs.readFile(filePath, 'utf8');
|
// 处理非 JSON 文件
|
||||||
|
|
||||||
if (ext === '.key' || ext === '.pem') {
|
if (ext === '.key' || ext === '.pem') {
|
||||||
if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) {
|
if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) {
|
||||||
oauthProvider = 'private_key';
|
oauthProvider = 'private_key';
|
||||||
}
|
}
|
||||||
} else if (ext === '.txt') {
|
} else if (ext === '.txt') {
|
||||||
if (content.includes('api_key') || content.includes('apikey')) {
|
if (content.includes('api_key') || content.includes('apikey')) {
|
||||||
oauthProvider = 'api_key';
|
if (type === 'oauth') type = 'api-key';
|
||||||
|
if (oauthProvider === 'unknown') oauthProvider = 'api_key';
|
||||||
}
|
}
|
||||||
} else if (ext === '.oauth' || ext === '.creds') {
|
} else if (ext === '.oauth' || ext === '.creds') {
|
||||||
oauthProvider = 'oauth_credentials';
|
if (oauthProvider === 'unknown') oauthProvider = 'oauth_credentials';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (readError) {
|
} catch (readError) {
|
||||||
|
|
@ -133,14 +178,14 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) {
|
||||||
name: filename,
|
name: filename,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
type: type,
|
type: type, // 用于前端图标显示的关键字段
|
||||||
provider: oauthProvider,
|
provider: oauthProvider,
|
||||||
extension: ext,
|
extension: ext,
|
||||||
modified: stats.mtime.toISOString(),
|
modified: stats.mtime.toISOString(),
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
isUsed: isPathUsed(relativePath, filename, usedPaths),
|
isUsed: isPathUsed(relativePath, filename, usedPaths),
|
||||||
usageInfo: usageInfo, // 新增详细关联信息
|
usageInfo: usageInfo,
|
||||||
preview: content.substring(0, 100) + (content.length > 100 ? '...' : '')
|
preview: content.substring(0, 100) + (content.length > 100 ? '...' : '')
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -988,21 +988,19 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
|
||||||
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext);
|
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CONFIG?._monitorRequestId) {
|
// 执行插件钩子:内容生成后
|
||||||
// 执行插件钩子:内容生成后
|
try {
|
||||||
try {
|
const pluginManager = getPluginManager();
|
||||||
const pluginManager = getPluginManager();
|
await pluginManager.executeHook('onContentGenerated', {
|
||||||
await pluginManager.executeHook('onContentGenerated', {
|
...CONFIG,
|
||||||
...CONFIG,
|
originalRequestBody,
|
||||||
originalRequestBody,
|
processedRequestBody,
|
||||||
processedRequestBody,
|
fromProvider,
|
||||||
fromProvider,
|
toProvider,
|
||||||
toProvider,
|
model,
|
||||||
model,
|
isStream
|
||||||
isStream
|
});
|
||||||
});
|
} catch (e) { /* 静默失败,不影响主流程 */ }
|
||||||
} catch (e) { /* 静默失败,不影响主流程 */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一日志工具类
|
* 统一日志工具类
|
||||||
|
|
@ -20,7 +21,7 @@ class Logger {
|
||||||
};
|
};
|
||||||
this.currentLogFile = null;
|
this.currentLogFile = null;
|
||||||
this.logStream = null;
|
this.logStream = null;
|
||||||
this.currentRequestId = null; // 当前请求ID
|
this.asyncStorage = new AsyncLocalStorage(); // 使用 AsyncLocalStorage 存储请求上下文
|
||||||
this.requestContext = new Map(); // 存储请求上下文
|
this.requestContext = new Map(); // 存储请求上下文
|
||||||
this.contextTTL = 5 * 60 * 1000; // 请求上下文 TTL:5 分钟
|
this.contextTTL = 5 * 60 * 1000; // 请求上下文 TTL:5 分钟
|
||||||
this._contextCleanupTimer = null;
|
this._contextCleanupTimer = null;
|
||||||
|
|
@ -32,6 +33,7 @@ class Logger {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化日志配置
|
* 初始化日志配置
|
||||||
* @param {Object} config - 日志配置对象
|
* @param {Object} config - 日志配置对象
|
||||||
|
|
@ -80,7 +82,22 @@ class Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置请求上下文
|
* 在请求上下文中运行
|
||||||
|
* @param {string} requestId - 请求ID
|
||||||
|
* @param {Function} callback - 回调函数
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
runWithContext(requestId, callback) {
|
||||||
|
if (!requestId) {
|
||||||
|
requestId = randomUUID().substring(0, 8);
|
||||||
|
}
|
||||||
|
this.requestContext.set(requestId, { _createdAt: Date.now() });
|
||||||
|
this._ensureContextCleanup();
|
||||||
|
return this.asyncStorage.run(requestId, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置请求上下文 (不推荐直接使用,建议使用 runWithContext)
|
||||||
* @param {string} requestId - 请求ID
|
* @param {string} requestId - 请求ID
|
||||||
* @param {Object} context - 上下文信息
|
* @param {Object} context - 上下文信息
|
||||||
*/
|
*/
|
||||||
|
|
@ -88,7 +105,7 @@ class Logger {
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
requestId = randomUUID().substring(0, 8);
|
requestId = randomUUID().substring(0, 8);
|
||||||
}
|
}
|
||||||
this.currentRequestId = requestId;
|
this.asyncStorage.enterWith(requestId);
|
||||||
this.requestContext.set(requestId, { ...context, _createdAt: Date.now() });
|
this.requestContext.set(requestId, { ...context, _createdAt: Date.now() });
|
||||||
this._ensureContextCleanup();
|
this._ensureContextCleanup();
|
||||||
return requestId;
|
return requestId;
|
||||||
|
|
@ -99,8 +116,8 @@ class Logger {
|
||||||
* @returns {string} 请求ID
|
* @returns {string} 请求ID
|
||||||
*/
|
*/
|
||||||
getCurrentRequestId() {
|
getCurrentRequestId() {
|
||||||
// 从上下文中获取当前请求ID
|
// 从 AsyncLocalStorage 中获取当前请求ID
|
||||||
return this.currentRequestId;
|
return this.asyncStorage.getStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,6 +126,9 @@ class Logger {
|
||||||
* @returns {Object} 上下文信息
|
* @returns {Object} 上下文信息
|
||||||
*/
|
*/
|
||||||
getRequestContext(requestId) {
|
getRequestContext(requestId) {
|
||||||
|
if (!requestId) {
|
||||||
|
requestId = this.getCurrentRequestId();
|
||||||
|
}
|
||||||
return this.requestContext.get(requestId) || {};
|
return this.requestContext.get(requestId) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,9 +140,11 @@ class Logger {
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
this.requestContext.delete(requestId);
|
this.requestContext.delete(requestId);
|
||||||
}
|
}
|
||||||
this.currentRequestId = null;
|
// AsyncLocalStorage 不需要手动清除,run() 会在结束时自动处理
|
||||||
|
// 如果使用了 enterWith,则没有简单的方法在该异步路径中清除
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动定期清理过期请求上下文的定时器(防止内存泄漏)
|
* 启动定期清理过期请求上下文的定时器(防止内存泄漏)
|
||||||
* 每 60 秒扫描一次,清除超过 contextTTL 的条目
|
* 每 60 秒扫描一次,清除超过 contextTTL 的条目
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ import {
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loadConfiguration,
|
loadConfiguration,
|
||||||
saveConfiguration
|
saveConfiguration,
|
||||||
|
generateApiKey
|
||||||
} from './config-manager.js';
|
} from './config-manager.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -241,6 +242,7 @@ window.loadConfigList = loadConfigList;
|
||||||
window.closeConfigModal = closeConfigModal;
|
window.closeConfigModal = closeConfigModal;
|
||||||
window.copyConfigContent = copyConfigContent;
|
window.copyConfigContent = copyConfigContent;
|
||||||
window.reloadConfig = reloadConfig;
|
window.reloadConfig = reloadConfig;
|
||||||
|
window.generateApiKey = generateApiKey;
|
||||||
|
|
||||||
// 用量管理相关全局函数
|
// 用量管理相关全局函数
|
||||||
window.refreshUsage = refreshUsage;
|
window.refreshUsage = refreshUsage;
|
||||||
|
|
|
||||||
|
|
@ -348,8 +348,30 @@ async function saveConfiguration() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动生成 API 密钥
|
||||||
|
*/
|
||||||
|
function generateApiKey() {
|
||||||
|
const apiKeyEl = document.getElementById('apiKey');
|
||||||
|
if (!apiKeyEl) return;
|
||||||
|
|
||||||
|
// 生成 32 位 16 进制随机字符串
|
||||||
|
const array = new Uint8Array(16);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
const randomKey = 'sk-' + Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
apiKeyEl.value = randomKey;
|
||||||
|
|
||||||
|
showToast(t('common.success'), t('config.apiKey.generated') || '已生成新的 API 密钥', 'success');
|
||||||
|
|
||||||
|
// 触发输入框的 change 事件
|
||||||
|
apiKeyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
apiKeyEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
loadConfiguration,
|
loadConfiguration,
|
||||||
saveConfiguration,
|
saveConfiguration,
|
||||||
updateConfigProviderConfigs
|
updateConfigProviderConfigs,
|
||||||
|
generateApiKey
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js';
|
import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js';
|
||||||
import { showToast } from './utils.js';
|
import { showToast } from './utils.js';
|
||||||
import { fileUploadHandler } from './file-upload.js';
|
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { checkUpdate, performUpdate } from './provider-manager.js';
|
import { checkUpdate, performUpdate } from './provider-manager.js';
|
||||||
|
|
||||||
|
|
@ -151,7 +150,13 @@ function initEventListeners() {
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
if (elements.saveConfigBtn) {
|
if (elements.saveConfigBtn) {
|
||||||
elements.saveConfigBtn.addEventListener('click', saveConfiguration);
|
elements.saveConfigBtn.addEventListener('click', () => {
|
||||||
|
if (window.saveConfiguration) {
|
||||||
|
window.saveConfiguration();
|
||||||
|
} else if (saveConfiguration) {
|
||||||
|
saveConfiguration();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置配置
|
// 重置配置
|
||||||
|
|
@ -179,6 +184,18 @@ function initEventListeners() {
|
||||||
button.addEventListener('click', handlePasswordToggle);
|
button.addEventListener('click', handlePasswordToggle);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 生成 API 密钥按钮监听
|
||||||
|
const generateApiKeyBtn = document.getElementById('generateApiKey');
|
||||||
|
if (generateApiKeyBtn) {
|
||||||
|
generateApiKeyBtn.addEventListener('click', () => {
|
||||||
|
if (window.generateApiKey) {
|
||||||
|
window.generateApiKey();
|
||||||
|
} else {
|
||||||
|
console.error('generateApiKey function not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 生成凭据按钮监听
|
// 生成凭据按钮监听
|
||||||
document.querySelectorAll('.generate-creds-btn').forEach(button => {
|
document.querySelectorAll('.generate-creds-btn').forEach(button => {
|
||||||
button.addEventListener('click', handleGenerateCreds);
|
button.addEventListener('click', handleGenerateCreds);
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,9 @@ const translations = {
|
||||||
// Config
|
// Config
|
||||||
'config.title': '配置管理',
|
'config.title': '配置管理',
|
||||||
'config.apiKey': 'API密钥',
|
'config.apiKey': 'API密钥',
|
||||||
|
'config.apiKey.generate': '生成',
|
||||||
|
'config.apiKey.generateTitle': '自动生成API密钥',
|
||||||
|
'config.apiKey.generated': '已生成新的 API 密钥',
|
||||||
'config.apiKeyPlaceholder': '请输入API密钥',
|
'config.apiKeyPlaceholder': '请输入API密钥',
|
||||||
'config.host': '监听地址',
|
'config.host': '监听地址',
|
||||||
'config.hostPlaceholder': '例如: 127.0.0.1',
|
'config.hostPlaceholder': '例如: 127.0.0.1',
|
||||||
|
|
@ -291,7 +294,7 @@ const translations = {
|
||||||
'config.advanced.loginExpiryNote': '管理后台登录后的 Token 有效期,默认 3600 秒 (1小时)',
|
'config.advanced.loginExpiryNote': '管理后台登录后的 Token 有效期,默认 3600 秒 (1小时)',
|
||||||
'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)',
|
'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)',
|
||||||
'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json',
|
'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json',
|
||||||
'config.advanced.poolNote': '使用默认路径配置需添加一个空节点',
|
'config.advanced.poolNote': '如使用客户端默认授权配置需使用空节点',
|
||||||
'config.advanced.maxErrorCount': '提供商最大错误次数',
|
'config.advanced.maxErrorCount': '提供商最大错误次数',
|
||||||
'config.advanced.maxErrorCountPlaceholder': '默认: 10',
|
'config.advanced.maxErrorCountPlaceholder': '默认: 10',
|
||||||
'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 10 次',
|
'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 10 次',
|
||||||
|
|
@ -423,7 +426,7 @@ const translations = {
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
'providers.title': '提供商池管理',
|
'providers.title': '提供商池管理',
|
||||||
'providers.note': '使用默认路径配置需添加一个空节点',
|
'providers.note': '如使用客户端默认授权配置需使用空节点',
|
||||||
'providers.activeConnections': '活动连接',
|
'providers.activeConnections': '活动连接',
|
||||||
'providers.activeProviders': '活跃提供商',
|
'providers.activeProviders': '活跃提供商',
|
||||||
'providers.healthyProviders': '健康提供商',
|
'providers.healthyProviders': '健康提供商',
|
||||||
|
|
@ -693,8 +696,12 @@ const translations = {
|
||||||
'guide.flow.step2.method2.item1': '新增提供商节点',
|
'guide.flow.step2.method2.item1': '新增提供商节点',
|
||||||
'guide.flow.step2.method2.item2': '上传已有的授权文件',
|
'guide.flow.step2.method2.item2': '上传已有的授权文件',
|
||||||
'guide.flow.step2.method2.item3': '手动关联凭据路径',
|
'guide.flow.step2.method2.item3': '手动关联凭据路径',
|
||||||
|
'guide.flow.step2.method3': '方式三:对接提供商 API',
|
||||||
|
'guide.flow.step2.method3.item1': '在「提供商池管理」新增对应协议的节点',
|
||||||
|
'guide.flow.step2.method3.item2': '填入 API Key 和端点 (Base URL)',
|
||||||
|
'guide.flow.step2.method3.item3': '无需生成或上传 OAuth 授权文件',
|
||||||
'guide.flow.step3.title': '管理凭据',
|
'guide.flow.step3.title': '管理凭据',
|
||||||
'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据',
|
'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据(非授权提供商可忽略)',
|
||||||
'guide.flow.step3.item1': '查看已生成的凭据文件',
|
'guide.flow.step3.item1': '查看已生成的凭据文件',
|
||||||
'guide.flow.step3.item2': '自动关联到提供商池',
|
'guide.flow.step3.item2': '自动关联到提供商池',
|
||||||
'guide.flow.step3.item3': '删除无效凭据',
|
'guide.flow.step3.item3': '删除无效凭据',
|
||||||
|
|
@ -806,6 +813,12 @@ const translations = {
|
||||||
'login.passwordPlaceholder': '请输入密码',
|
'login.passwordPlaceholder': '请输入密码',
|
||||||
'login.error.empty': '请输入密码',
|
'login.error.empty': '请输入密码',
|
||||||
'login.error.incorrect': '密码错误,请重试',
|
'login.error.incorrect': '密码错误,请重试',
|
||||||
|
'login.error.incorrectWithLock': '密码错误。账户已被锁定 {time} 分钟。',
|
||||||
|
'login.error.incorrectWithRemaining': '密码错误。还剩 {count} 次尝试机会。',
|
||||||
|
'login.error.locked': '账户因多次尝试失败而被暂时锁定。请在 {time} 秒后重试。',
|
||||||
|
'login.error.tooFrequent': '请求过于频繁,请稍候再试。',
|
||||||
|
'login.error.postOnly': '仅支持 POST 请求',
|
||||||
|
'login.error.invalidJson': '请求格式错误 (JSON)',
|
||||||
'login.error.failed': '登录失败,请检查网络连接',
|
'login.error.failed': '登录失败,请检查网络连接',
|
||||||
'login.button': '登录',
|
'login.button': '登录',
|
||||||
'login.loggingIn': '登录中...',
|
'login.loggingIn': '登录中...',
|
||||||
|
|
@ -1022,6 +1035,9 @@ const translations = {
|
||||||
// Config
|
// Config
|
||||||
'config.title': 'Configuration Management',
|
'config.title': 'Configuration Management',
|
||||||
'config.apiKey': 'API Key',
|
'config.apiKey': 'API Key',
|
||||||
|
'config.apiKey.generate': 'Generate',
|
||||||
|
'config.apiKey.generateTitle': 'Automatically generate API key',
|
||||||
|
'config.apiKey.generated': 'New API key generated',
|
||||||
'config.apiKeyPlaceholder': 'Please enter API key',
|
'config.apiKeyPlaceholder': 'Please enter API key',
|
||||||
'config.host': 'Listen Address',
|
'config.host': 'Listen Address',
|
||||||
'config.hostPlaceholder': 'e.g.: 127.0.0.1',
|
'config.hostPlaceholder': 'e.g.: 127.0.0.1',
|
||||||
|
|
@ -1101,7 +1117,7 @@ const translations = {
|
||||||
'config.advanced.loginExpiryNote': 'Token validity period after management console login, default 3600 seconds (1 hour)',
|
'config.advanced.loginExpiryNote': 'Token validity period after management console login, default 3600 seconds (1 hour)',
|
||||||
'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)',
|
'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)',
|
||||||
'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json',
|
'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json',
|
||||||
'config.advanced.poolNote': 'To use default path configuration, add an empty node',
|
'config.advanced.poolNote': 'If using default client authorization config, use an empty node',
|
||||||
'config.advanced.maxErrorCount': 'Provider Max Error Count',
|
'config.advanced.maxErrorCount': 'Provider Max Error Count',
|
||||||
'config.advanced.maxErrorCountPlaceholder': 'Default: 10',
|
'config.advanced.maxErrorCountPlaceholder': 'Default: 10',
|
||||||
'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 10',
|
'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 10',
|
||||||
|
|
@ -1233,7 +1249,7 @@ const translations = {
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
'providers.title': 'Provider Pool Management',
|
'providers.title': 'Provider Pool Management',
|
||||||
'providers.note': 'To use default path configuration, add an empty node',
|
'providers.note': 'If using default client authorization config, use an empty node',
|
||||||
'providers.activeConnections': 'Active Connections',
|
'providers.activeConnections': 'Active Connections',
|
||||||
'providers.activeProviders': 'Active Providers',
|
'providers.activeProviders': 'Active Providers',
|
||||||
'providers.healthyProviders': 'Healthy Providers',
|
'providers.healthyProviders': 'Healthy Providers',
|
||||||
|
|
@ -1503,8 +1519,12 @@ const translations = {
|
||||||
'guide.flow.step2.method2.item1': 'Add new provider node',
|
'guide.flow.step2.method2.item1': 'Add new provider node',
|
||||||
'guide.flow.step2.method2.item2': 'Upload existing authorization file',
|
'guide.flow.step2.method2.item2': 'Upload existing authorization file',
|
||||||
'guide.flow.step2.method2.item3': 'Manually link credential path',
|
'guide.flow.step2.method2.item3': 'Manually link credential path',
|
||||||
|
'guide.flow.step2.method3': 'Method 3: Provider API Integration',
|
||||||
|
'guide.flow.step2.method3.item1': 'Add node for corresponding protocol in "Provider Pools"',
|
||||||
|
'guide.flow.step2.method3.item2': 'Enter API Key and Endpoint (Base URL)',
|
||||||
|
'guide.flow.step2.method3.item3': 'No OAuth credential file generation/upload required',
|
||||||
'guide.flow.step3.title': 'Manage Credentials',
|
'guide.flow.step3.title': 'Manage Credentials',
|
||||||
'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" page',
|
'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" (Ignore for non-OAuth providers)',
|
||||||
'guide.flow.step3.item1': 'View generated credential files',
|
'guide.flow.step3.item1': 'View generated credential files',
|
||||||
'guide.flow.step3.item2': 'Auto-link to provider pool',
|
'guide.flow.step3.item2': 'Auto-link to provider pool',
|
||||||
'guide.flow.step3.item3': 'Delete invalid credentials',
|
'guide.flow.step3.item3': 'Delete invalid credentials',
|
||||||
|
|
@ -1616,6 +1636,12 @@ const translations = {
|
||||||
'login.passwordPlaceholder': 'Please enter password',
|
'login.passwordPlaceholder': 'Please enter password',
|
||||||
'login.error.empty': 'Please enter password',
|
'login.error.empty': 'Please enter password',
|
||||||
'login.error.incorrect': 'Incorrect password, please try again',
|
'login.error.incorrect': 'Incorrect password, please try again',
|
||||||
|
'login.error.incorrectWithLock': 'Incorrect password. Account locked for {time} minutes.',
|
||||||
|
'login.error.incorrectWithRemaining': 'Incorrect password. {count} attempts remaining.',
|
||||||
|
'login.error.locked': 'Account temporarily locked due to too many failed attempts. Please try again in {time} seconds.',
|
||||||
|
'login.error.tooFrequent': 'Too many requests, please slow down.',
|
||||||
|
'login.error.postOnly': 'Only POST requests are supported',
|
||||||
|
'login.error.invalidJson': 'Invalid request format (JSON)',
|
||||||
'login.error.failed': 'Login failed, please check your network connection',
|
'login.error.failed': 'Login failed, please check your network connection',
|
||||||
'login.button': 'Login',
|
'login.button': 'Login',
|
||||||
'login.loggingIn': 'Logging in...',
|
'login.loggingIn': 'Logging in...',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ let isLoadingConfigs = false; // 防止重复加载配置
|
||||||
* @param {string} statusFilter - 状态过滤
|
* @param {string} statusFilter - 状态过滤
|
||||||
*/
|
*/
|
||||||
function searchConfigs(searchTerm = '', statusFilter = '', providerFilter = '') {
|
function searchConfigs(searchTerm = '', statusFilter = '', providerFilter = '') {
|
||||||
|
// 确保 searchTerm 是字符串,防止事件对象等非字符串被传入
|
||||||
|
if (typeof searchTerm !== 'string') {
|
||||||
|
searchTerm = '';
|
||||||
|
}
|
||||||
|
|
||||||
if (!allConfigs.length) {
|
if (!allConfigs.length) {
|
||||||
console.log('没有配置数据可搜索');
|
console.log('没有配置数据可搜索');
|
||||||
return;
|
return;
|
||||||
|
|
@ -88,7 +93,11 @@ function createConfigItemElement(config, index) {
|
||||||
const typeIcon = config.type === 'oauth' ? 'fa-key' :
|
const typeIcon = config.type === 'oauth' ? 'fa-key' :
|
||||||
config.type === 'api-key' ? 'fa-lock' :
|
config.type === 'api-key' ? 'fa-lock' :
|
||||||
config.type === 'provider-pool' ? 'fa-network-wired' :
|
config.type === 'provider-pool' ? 'fa-network-wired' :
|
||||||
config.type === 'system-prompt' ? 'fa-file-text' : 'fa-file-code';
|
config.type === 'system-prompt' ? 'fa-file-text' :
|
||||||
|
config.type === 'plugins' ? 'fa-plug' :
|
||||||
|
config.type === 'usage' ? 'fa-chart-line' :
|
||||||
|
config.type === 'config' ? 'fa-cog' :
|
||||||
|
config.type === 'database' ? 'fa-database' : 'fa-file-code';
|
||||||
|
|
||||||
// 检测提供商信息
|
// 检测提供商信息
|
||||||
const providerInfo = detectProviderFromPath(config.path);
|
const providerInfo = detectProviderFromPath(config.path);
|
||||||
|
|
@ -453,6 +462,11 @@ function updateStats() {
|
||||||
* @param {string} providerFilter - 提供商过滤
|
* @param {string} providerFilter - 提供商过滤
|
||||||
*/
|
*/
|
||||||
async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter = '') {
|
async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter = '') {
|
||||||
|
// 确保 searchTerm 是字符串,处理事件监听器直接调用的情况
|
||||||
|
if (typeof searchTerm !== 'string') {
|
||||||
|
searchTerm = '';
|
||||||
|
}
|
||||||
|
|
||||||
// 防止重复加载
|
// 防止重复加载
|
||||||
if (isLoadingConfigs) {
|
if (isLoadingConfigs) {
|
||||||
console.log('正在加载配置列表,跳过重复调用');
|
console.log('正在加载配置列表,跳过重复调用');
|
||||||
|
|
@ -912,7 +926,7 @@ function initUploadConfigManager() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
refreshBtn.addEventListener('click', loadConfigList);
|
refreshBtn.addEventListener('click', () => loadConfigList());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadAllBtn) {
|
if (downloadAllBtn) {
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,25 @@ textarea.form-control {
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-input-wrapper {
|
.password-input-wrapper {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-key-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-input-wrapper .form-control {
|
.password-input-wrapper .form-control {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,14 @@
|
||||||
<div class="form-group password-input-group">
|
<div class="form-group password-input-group">
|
||||||
<label for="apiKey" data-i18n="config.apiKey">API密钥</label>
|
<label for="apiKey" data-i18n="config.apiKey">API密钥</label>
|
||||||
<div class="password-input-wrapper">
|
<div class="password-input-wrapper">
|
||||||
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
|
<div class="input-with-toggle">
|
||||||
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
|
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
|
||||||
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary generate-key-btn" id="generateApiKey" data-i18n-title="config.apiKey.generateTitle" title="自动生成API密钥">
|
||||||
|
<i class="fas fa-magic"></i> <span data-i18n="config.apiKey.generate">生成</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -318,10 +323,12 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="adminPassword" data-i18n="config.advanced.adminPassword">后台登录密码</label>
|
<label for="adminPassword" data-i18n="config.advanced.adminPassword">后台登录密码</label>
|
||||||
<div class="password-input-wrapper">
|
<div class="password-input-wrapper">
|
||||||
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
|
<div class="input-with-toggle">
|
||||||
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
|
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
|
||||||
</button>
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text" data-i18n="config.advanced.adminPasswordNote">修改后需要重新登录</small>
|
<small class="form-text" data-i18n="config.advanced.adminPasswordNote">修改后需要重新登录</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,15 @@
|
||||||
<li data-i18n="guide.flow.step2.method2.item3">手动关联凭据路径</li>
|
<li data-i18n="guide.flow.step2.method2.item3">手动关联凭据路径</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="branch-divider" data-i18n="guide.flow.step2.or">或</div>
|
||||||
|
<div class="branch-option">
|
||||||
|
<div class="branch-label" data-i18n="guide.flow.step2.method3">方式三:对接提供商 API</div>
|
||||||
|
<ul>
|
||||||
|
<li data-i18n="guide.flow.step2.method3.item1">在「配置管理」设置 API Key 和端点</li>
|
||||||
|
<li data-i18n="guide.flow.step2.method3.item2">系统自动识别并对接</li>
|
||||||
|
<li data-i18n="guide.flow.step2.method3.item3">无需手动上传凭据</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,10 @@
|
||||||
.config-item-icon-wrapper.api-key { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
.config-item-icon-wrapper.api-key { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
||||||
.config-item-icon-wrapper.provider-pool { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; }
|
.config-item-icon-wrapper.provider-pool { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; }
|
||||||
.config-item-icon-wrapper.system-prompt { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
|
.config-item-icon-wrapper.system-prompt { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
|
||||||
|
.config-item-icon-wrapper.plugins { background: rgba(236, 72, 153, 0.1); color: #ec4899; }
|
||||||
|
.config-item-icon-wrapper.usage { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
|
||||||
|
.config-item-icon-wrapper.config { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
||||||
|
.config-item-icon-wrapper.database { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
||||||
.config-item-icon-wrapper.other { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
.config-item-icon-wrapper.other { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
||||||
|
|
||||||
.config-item-title-area {
|
.config-item-title-area {
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,8 @@
|
||||||
// 跳转到主页
|
// 跳转到主页
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} else {
|
} else {
|
||||||
showError(data.message || t('login.error.incorrect'));
|
const errorMsg = data.messageCode ? t(data.messageCode, data.messageParams) : (data.message || t('login.error.incorrect'));
|
||||||
|
showError(errorMsg);
|
||||||
loginButton.disabled = false;
|
loginButton.disabled = false;
|
||||||
loginButton.innerHTML = t('login.button');
|
loginButton.innerHTML = t('login.button');
|
||||||
passwordInput.value = '';
|
passwordInput.value = '';
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1010
static/potluck.html
1010
static/potluck.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue