Compare commits
3 Commits
b186f9b80e
...
42fc3f2cf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fc3f2cf3 | ||
|
|
a18e45ee78 | ||
|
|
5e01993120 |
12
.env.example
12
.env.example
@@ -6,16 +6,26 @@ FACTORY_API_KEY=your_factory_api_key_here
|
|||||||
# 方式2:使用refresh token自动刷新(次优先级)
|
# 方式2:使用refresh token自动刷新(次优先级)
|
||||||
DROID_REFRESH_KEY=your_refresh_token_here
|
DROID_REFRESH_KEY=your_refresh_token_here
|
||||||
|
|
||||||
|
# refresh token 请求超时与重试(可选)
|
||||||
|
DROID_REFRESH_TIMEOUT_MS=15000
|
||||||
|
DROID_REFRESH_RETRIES=2
|
||||||
|
DROID_REFRESH_RETRY_BASE_MS=500
|
||||||
|
|
||||||
# 阿里云日志服务配置
|
# 阿里云日志服务配置
|
||||||
|
SLS_ENABLED=false
|
||||||
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
||||||
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
||||||
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
|
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
|
||||||
ALIYUN_SLS_PROJECT=your_project_name
|
ALIYUN_SLS_PROJECT=your_project_name
|
||||||
ALIYUN_SLS_LOGSTORE=your_logstore_name
|
ALIYUN_SLS_LOGSTORE=your_logstore_name
|
||||||
|
|
||||||
# 账号同步脚本配置 (sync-accounts.sh)
|
# Deploy Configuration (sync-accounts.sh)
|
||||||
SYNC_SERVER=user@your-server.com
|
SYNC_SERVER=user@your-server.com
|
||||||
SYNC_REMOTE_PATH=/opt/droid2api
|
SYNC_REMOTE_PATH=/opt/droid2api
|
||||||
DEPLOY_TYPE=docker-compose
|
DEPLOY_TYPE=docker-compose
|
||||||
DOCKER_SERVICE_NAME=droid2api
|
DOCKER_SERVICE_NAME=droid2api
|
||||||
PM2_APP_NAME=droid2api
|
PM2_APP_NAME=droid2api
|
||||||
|
|
||||||
|
# Cloudflare Tunnel Configuration (Optional)
|
||||||
|
# Get token from: https://one.dash.cloudflare.com/ -> Networks -> Tunnels
|
||||||
|
TUNNEL_TOKEN=
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -285,6 +285,29 @@ Docker部署支持以下环境变量:
|
|||||||
- `PORT` - 服务端口(默认3000)
|
- `PORT` - 服务端口(默认3000)
|
||||||
- `NODE_ENV` - 运行环境(production/development)
|
- `NODE_ENV` - 运行环境(production/development)
|
||||||
|
|
||||||
|
### Cloudflare Tunnel 部署 (推荐)
|
||||||
|
|
||||||
|
本项目支持通过 Cloudflare Tunnel 进行安全暴露,无需在服务器防火墙开放端口,即可享受 DDoS 防护和 SSL 加密。
|
||||||
|
|
||||||
|
1. **获取 Tunnel Token**
|
||||||
|
- 访问 [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
|
||||||
|
- 进入 `Networks` -> `Tunnels` -> `Create a tunnel`
|
||||||
|
- 选择 `Cloudflared` 部署模式
|
||||||
|
- 在 Public Hostname 中设置你的域名,指向 `http://droid2api:3000`
|
||||||
|
|
||||||
|
2. **配置环境变量**
|
||||||
|
在 `.env` 或服务器环境变量中设置 Token:
|
||||||
|
```bash
|
||||||
|
export TUNNEL_TOKEN="your_tunnel_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
系统会自动启动 `cloudflared` 容器并建立安全隧道。
|
||||||
|
|
||||||
### Claude Code集成
|
### Claude Code集成
|
||||||
|
|
||||||
#### 配置Claude Code使用droid2api
|
#### 配置Claude Code使用droid2api
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { logInfo, logDebug, logError } from './logger.js';
|
import { logInfo, logDebug, logError } from './logger.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
|
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
|
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
|
||||||
@@ -21,6 +21,7 @@ const CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
|||||||
class AccountManager {
|
class AccountManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.accounts = [];
|
this.accounts = [];
|
||||||
|
this.refreshLocks = new Map(); // 刷新锁,避免同一账号并发刷新
|
||||||
this.settings = {
|
this.settings = {
|
||||||
algorithm: 'weighted', // 'weighted' or 'simple'
|
algorithm: 'weighted', // 'weighted' or 'simple'
|
||||||
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
|
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
|
||||||
@@ -174,12 +175,30 @@ class AccountManager {
|
|||||||
const needsRefresh = !account.access_token || this._shouldRefresh(account);
|
const needsRefresh = !account.access_token || this._shouldRefresh(account);
|
||||||
|
|
||||||
if (needsRefresh) {
|
if (needsRefresh) {
|
||||||
await this._refreshToken(account);
|
await this._refreshTokenWithLock(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
return account.access_token;
|
return account.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带锁刷新,避免同一账号并发刷新
|
||||||
|
*/
|
||||||
|
async _refreshTokenWithLock(account) {
|
||||||
|
const existing = this.refreshLocks.get(account.id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPromise = this._refreshToken(account)
|
||||||
|
.finally(() => {
|
||||||
|
this.refreshLocks.delete(account.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refreshLocks.set(account.id, refreshPromise);
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否需要刷新 token
|
* 检查是否需要刷新 token
|
||||||
*/
|
*/
|
||||||
@@ -198,32 +217,15 @@ class AccountManager {
|
|||||||
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
|
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new URLSearchParams();
|
|
||||||
formData.append('grant_type', 'refresh_token');
|
|
||||||
formData.append('refresh_token', account.refresh_token);
|
|
||||||
formData.append('client_id', CLIENT_ID);
|
|
||||||
|
|
||||||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||||||
const fetchOptions = {
|
const refreshConfig = getRefreshConfig();
|
||||||
method: 'POST',
|
const data = await requestRefreshToken({
|
||||||
headers: {
|
refreshUrl: REFRESH_URL,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
refreshToken: account.refresh_token,
|
||||||
},
|
clientId: CLIENT_ID,
|
||||||
body: formData.toString()
|
proxyAgentInfo,
|
||||||
};
|
...refreshConfig
|
||||||
|
});
|
||||||
if (proxyAgentInfo?.agent) {
|
|
||||||
fetchOptions.agent = proxyAgentInfo.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(REFRESH_URL, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`刷新失败: ${response.status} ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 更新账号信息
|
// 更新账号信息
|
||||||
account.access_token = data.access_token;
|
account.access_token = data.access_token;
|
||||||
|
|||||||
106
auth.js
106
auth.js
@@ -1,10 +1,10 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { logDebug, logError, logInfo } from './logger.js';
|
import { logDebug, logError, logInfo } from './logger.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
||||||
|
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
|
||||||
|
|
||||||
// State management for API key and refresh
|
// State management for API key and refresh
|
||||||
let currentApiKey = null;
|
let currentApiKey = null;
|
||||||
@@ -16,6 +16,7 @@ let authFilePath = null;
|
|||||||
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
||||||
let multiAccountMode = false; // 是否启用多账号模式
|
let multiAccountMode = false; // 是否启用多账号模式
|
||||||
let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调
|
let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调
|
||||||
|
let refreshInFlight = null; // 刷新锁,避免并发刷新
|
||||||
|
|
||||||
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
||||||
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
||||||
@@ -148,64 +149,58 @@ async function refreshApiKey() {
|
|||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientId) {
|
if (refreshInFlight) {
|
||||||
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
return refreshInFlight;
|
||||||
logDebug(`Using fixed client ID: ${clientId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo('Refreshing API key...');
|
refreshInFlight = (async () => {
|
||||||
|
if (!clientId) {
|
||||||
|
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
||||||
|
logDebug(`Using fixed client ID: ${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('Refreshing API key...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||||||
|
const refreshConfig = getRefreshConfig();
|
||||||
|
const data = await requestRefreshToken({
|
||||||
|
refreshUrl: REFRESH_URL,
|
||||||
|
refreshToken: currentRefreshToken,
|
||||||
|
clientId,
|
||||||
|
proxyAgentInfo,
|
||||||
|
...refreshConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tokens
|
||||||
|
currentApiKey = data.access_token;
|
||||||
|
currentRefreshToken = data.refresh_token;
|
||||||
|
lastRefreshTime = Date.now();
|
||||||
|
|
||||||
|
// Log user info
|
||||||
|
if (data.user) {
|
||||||
|
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
|
||||||
|
logInfo(`User ID: ${data.user.id}`);
|
||||||
|
logInfo(`Organization ID: ${data.organization_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save tokens to file
|
||||||
|
saveTokens(data.access_token, data.refresh_token);
|
||||||
|
|
||||||
|
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
||||||
|
logInfo('API key refreshed successfully');
|
||||||
|
return data.access_token;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed to refresh API key', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create form data
|
return await refreshInFlight;
|
||||||
const formData = new URLSearchParams();
|
} finally {
|
||||||
formData.append('grant_type', 'refresh_token');
|
refreshInFlight = null;
|
||||||
formData.append('refresh_token', currentRefreshToken);
|
|
||||||
formData.append('client_id', clientId);
|
|
||||||
|
|
||||||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
|
||||||
const fetchOptions = {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
body: formData.toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (proxyAgentInfo?.agent) {
|
|
||||||
fetchOptions.agent = proxyAgentInfo.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(REFRESH_URL, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update tokens
|
|
||||||
currentApiKey = data.access_token;
|
|
||||||
currentRefreshToken = data.refresh_token;
|
|
||||||
lastRefreshTime = Date.now();
|
|
||||||
|
|
||||||
// Log user info
|
|
||||||
if (data.user) {
|
|
||||||
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
|
|
||||||
logInfo(`User ID: ${data.user.id}`);
|
|
||||||
logInfo(`Organization ID: ${data.organization_id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save tokens to file
|
|
||||||
saveTokens(data.access_token, data.refresh_token);
|
|
||||||
|
|
||||||
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
|
||||||
logInfo('API key refreshed successfully');
|
|
||||||
return data.access_token;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to refresh API key', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,4 +378,3 @@ export function getAuthStatus() {
|
|||||||
multiAccountMode: false
|
multiAccountMode: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,15 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
# Cloudflare Tunnel (可选)
|
||||||
|
# 需要在 .env 中设置 TUNNEL_TOKEN
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
container_name: droid2api_tunnel
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- droid2api
|
||||||
|
|||||||
87
log-sanitizer.js
Normal file
87
log-sanitizer.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const REDACTION = '[REDACTED]';
|
||||||
|
|
||||||
|
const REDACT_KEY_RE = /(authorization|x-api-key|api[-_]?key|access_token|refresh_token|client_secret|private_key|set-cookie|cookie|password|secret)/i;
|
||||||
|
const EMAIL_KEY_RE = /email/i;
|
||||||
|
const IP_KEY_RE = /(^ip$|ip_address|remote_address|x-forwarded-for)/i;
|
||||||
|
|
||||||
|
function maskEmail(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
return value.replace(/([A-Za-z0-9._%+-])([A-Za-z0-9._%+-]*)(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g, '$1***$3');
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskIp(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b/g, '$1.xxx');
|
||||||
|
masked = masked.replace(/\b([A-Fa-f0-9]{0,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, '****');
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskTokensInString(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+\b/g, 'Bearer ' + REDACTION);
|
||||||
|
masked = masked.replace(/\b(api_key|apikey|access_token|refresh_token|client_secret|password)=([^\s&]+)/gi, '$1=' + REDACTION);
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeString(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value;
|
||||||
|
masked = maskTokensInString(masked);
|
||||||
|
masked = maskEmail(masked);
|
||||||
|
masked = maskIp(masked);
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeValue(value, key, seen) {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
if (key && REDACT_KEY_RE.test(key)) {
|
||||||
|
return REDACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (key && EMAIL_KEY_RE.test(key)) {
|
||||||
|
return maskEmail(value);
|
||||||
|
}
|
||||||
|
if (key && IP_KEY_RE.test(key)) {
|
||||||
|
return maskIp(value);
|
||||||
|
}
|
||||||
|
return sanitizeString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => sanitizeValue(item, key, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return sanitizeObject(value, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeObject(value, seen) {
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
if (!seen) seen = new WeakSet();
|
||||||
|
if (seen.has(value)) return '[Circular]';
|
||||||
|
seen.add(value);
|
||||||
|
|
||||||
|
const output = Array.isArray(value) ? [] : {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
output[key] = sanitizeValue(val, key, seen);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeForLog(value) {
|
||||||
|
return sanitizeValue(value, null, new WeakSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeLogMessage(message) {
|
||||||
|
if (typeof message !== 'string') return message;
|
||||||
|
return sanitizeString(message);
|
||||||
|
}
|
||||||
27
logger.js
27
logger.js
@@ -1,28 +1,29 @@
|
|||||||
import { isDevMode } from './config.js';
|
import { isDevMode } from './config.js';
|
||||||
|
import { sanitizeForLog, sanitizeLogMessage } from './log-sanitizer.js';
|
||||||
|
|
||||||
export function logInfo(message, data = null) {
|
export function logInfo(message, data = null) {
|
||||||
console.log(`[INFO] ${message}`);
|
console.log(`[INFO] ${sanitizeLogMessage(message)}`);
|
||||||
if (data && isDevMode()) {
|
if (data && isDevMode()) {
|
||||||
console.log(JSON.stringify(data, null, 2));
|
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDebug(message, data = null) {
|
export function logDebug(message, data = null) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log(`[DEBUG] ${message}`);
|
console.log(`[DEBUG] ${sanitizeLogMessage(message)}`);
|
||||||
if (data) {
|
if (data) {
|
||||||
console.log(JSON.stringify(data, null, 2));
|
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(message, error = null) {
|
export function logError(message, error = null) {
|
||||||
console.error(`[ERROR] ${message}`);
|
console.error(`[ERROR] ${sanitizeLogMessage(message)}`);
|
||||||
if (error) {
|
if (error) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.error(error);
|
console.error(sanitizeForLog(error));
|
||||||
} else {
|
} else {
|
||||||
console.error(error.message || error);
|
console.error(sanitizeLogMessage(error.message || String(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,16 +31,16 @@ export function logError(message, error = null) {
|
|||||||
export function logRequest(method, url, headers = null, body = null) {
|
export function logRequest(method, url, headers = null, body = null) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log(`\n${'='.repeat(80)}`);
|
console.log(`\n${'='.repeat(80)}`);
|
||||||
console.log(`[REQUEST] ${method} ${url}`);
|
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
|
||||||
if (headers) {
|
if (headers) {
|
||||||
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
|
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
console.log('[BODY]', JSON.stringify(body, null, 2));
|
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
|
||||||
}
|
}
|
||||||
console.log('='.repeat(80) + '\n');
|
console.log('='.repeat(80) + '\n');
|
||||||
} else {
|
} else {
|
||||||
console.log(`[REQUEST] ${method} ${url}`);
|
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +49,10 @@ export function logResponse(status, headers = null, body = null) {
|
|||||||
console.log(`\n${'-'.repeat(80)}`);
|
console.log(`\n${'-'.repeat(80)}`);
|
||||||
console.log(`[RESPONSE] Status: ${status}`);
|
console.log(`[RESPONSE] Status: ${status}`);
|
||||||
if (headers) {
|
if (headers) {
|
||||||
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
|
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
console.log('[BODY]', JSON.stringify(body, null, 2));
|
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
|
||||||
}
|
}
|
||||||
console.log('-'.repeat(80) + '\n');
|
console.log('-'.repeat(80) + '\n');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js"
|
"dev": "node server.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
|
|||||||
132
refresh-client.js
Normal file
132
refresh-client.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 2;
|
||||||
|
const DEFAULT_RETRY_BASE_MS = 500;
|
||||||
|
const MAX_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
function normalizeNumber(value, fallback) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshConfig() {
|
||||||
|
return {
|
||||||
|
timeoutMs: normalizeNumber(process.env.DROID_REFRESH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
||||||
|
maxRetries: normalizeNumber(process.env.DROID_REFRESH_RETRIES, DEFAULT_MAX_RETRIES),
|
||||||
|
retryDelayMs: normalizeNumber(process.env.DROID_REFRESH_RETRY_BASE_MS, DEFAULT_RETRY_BASE_MS)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
if (!ms || ms <= 0) return Promise.resolve();
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.name === 'AbortError') return true;
|
||||||
|
const retryCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND']);
|
||||||
|
return retryCodes.has(error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryStatus(status) {
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterMs(response) {
|
||||||
|
if (!response?.headers?.get) return null;
|
||||||
|
const raw = response.headers.get('retry-after');
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const seconds = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(seconds)) {
|
||||||
|
return Math.max(0, seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMs = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(dateMs)) {
|
||||||
|
return Math.max(0, dateMs - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url, options, timeoutMs, fetchImpl) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetchImpl(url, { ...options, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildError(status, errorText) {
|
||||||
|
const message = `Failed to refresh token: ${status} ${errorText || ''}`.trim();
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestRefreshToken(options) {
|
||||||
|
const {
|
||||||
|
refreshUrl,
|
||||||
|
refreshToken,
|
||||||
|
clientId,
|
||||||
|
proxyAgentInfo,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
maxRetries = DEFAULT_MAX_RETRIES,
|
||||||
|
retryDelayMs = DEFAULT_RETRY_BASE_MS,
|
||||||
|
fetchImpl = fetch
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('grant_type', 'refresh_token');
|
||||||
|
formData.append('refresh_token', refreshToken);
|
||||||
|
formData.append('client_id', clientId);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: formData.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxyAgentInfo?.agent) {
|
||||||
|
fetchOptions.agent = proxyAgentInfo.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(refreshUrl, fetchOptions, timeoutMs, fetchImpl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
if (shouldRetryStatus(response.status) && attempt < maxRetries) {
|
||||||
|
const retryAfter = parseRetryAfterMs(response);
|
||||||
|
const delay = retryAfter ?? Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
|
||||||
|
attempt += 1;
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw buildError(response.status, errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (isRetryableError(error) && attempt < maxRetries) {
|
||||||
|
const delay = Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
|
||||||
|
attempt += 1;
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server.js
20
server.js
@@ -5,6 +5,7 @@ import router from './routes.js';
|
|||||||
import { initializeAuth } from './auth.js';
|
import { initializeAuth } from './auth.js';
|
||||||
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
||||||
import './sls-logger.js'; // 初始化阿里云日志服务
|
import './sls-logger.js'; // 初始化阿里云日志服务
|
||||||
|
import { sanitizeForLog } from './log-sanitizer.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ app.use((req, res, next) => {
|
|||||||
ip: req.ip || req.connection.remoteAddress
|
ip: req.ip || req.connection.remoteAddress
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const safeQuery = sanitizeForLog(errorInfo.query);
|
||||||
|
const safeBody = sanitizeForLog(errorInfo.body);
|
||||||
|
const safeHeaders = sanitizeForLog(errorInfo.headers);
|
||||||
|
const safeIp = sanitizeForLog(errorInfo.ip);
|
||||||
|
|
||||||
console.error('\n' + '='.repeat(80));
|
console.error('\n' + '='.repeat(80));
|
||||||
console.error('❌ 非法请求地址');
|
console.error('❌ 非法请求地址');
|
||||||
console.error('='.repeat(80));
|
console.error('='.repeat(80));
|
||||||
@@ -67,23 +73,23 @@ app.use((req, res, next) => {
|
|||||||
console.error(`路径: ${errorInfo.path}`);
|
console.error(`路径: ${errorInfo.path}`);
|
||||||
|
|
||||||
if (Object.keys(errorInfo.query).length > 0) {
|
if (Object.keys(errorInfo.query).length > 0) {
|
||||||
console.error(`查询参数: ${JSON.stringify(errorInfo.query, null, 2)}`);
|
console.error(`查询参数: ${JSON.stringify(safeQuery, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorInfo.body && Object.keys(errorInfo.body).length > 0) {
|
if (errorInfo.body && Object.keys(errorInfo.body).length > 0) {
|
||||||
console.error(`请求体: ${JSON.stringify(errorInfo.body, null, 2)}`);
|
console.error(`请求体: ${JSON.stringify(safeBody, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`客户端IP: ${errorInfo.ip}`);
|
console.error(`客户端IP: ${safeIp}`);
|
||||||
console.error(`User-Agent: ${errorInfo.headers['user-agent'] || 'N/A'}`);
|
console.error(`User-Agent: ${safeHeaders['user-agent'] || 'N/A'}`);
|
||||||
|
|
||||||
if (errorInfo.headers.referer) {
|
if (safeHeaders.referer) {
|
||||||
console.error(`来源: ${errorInfo.headers.referer}`);
|
console.error(`来源: ${safeHeaders.referer}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('='.repeat(80) + '\n');
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
logError('Invalid request path', errorInfo);
|
logError('Invalid request path', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import ALSClient from 'aliyun-log';
|
import ALSClient from 'aliyun-log';
|
||||||
|
import { sanitizeForLog } from './log-sanitizer.js';
|
||||||
|
|
||||||
// SLS 配置
|
// SLS 配置
|
||||||
const SLS_CONFIG = {
|
const SLS_CONFIG = {
|
||||||
@@ -18,8 +19,22 @@ const SLS_CONFIG = {
|
|||||||
logstore: process.env.ALIYUN_SLS_LOGSTORE
|
logstore: process.env.ALIYUN_SLS_LOGSTORE
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveSlsEnabled() {
|
||||||
|
const raw = process.env.SLS_ENABLED;
|
||||||
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(raw).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(value)) return false;
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLS_ENABLED = resolveSlsEnabled();
|
||||||
|
|
||||||
// 检查配置是否完整
|
// 检查配置是否完整
|
||||||
const isConfigured = Object.values(SLS_CONFIG).every(v => v);
|
const isConfigured = SLS_ENABLED && Object.values(SLS_CONFIG).every(v => v);
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
let logQueue = [];
|
let logQueue = [];
|
||||||
@@ -28,6 +43,9 @@ const FLUSH_INTERVAL_MS = 5000;
|
|||||||
|
|
||||||
// 初始化 SLS Client
|
// 初始化 SLS Client
|
||||||
function initClient() {
|
function initClient() {
|
||||||
|
if (!SLS_ENABLED) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
|
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
|
||||||
return null;
|
return null;
|
||||||
@@ -73,7 +91,7 @@ async function flushLogs() {
|
|||||||
// 定时刷新
|
// 定时刷新
|
||||||
let flushTimer = null;
|
let flushTimer = null;
|
||||||
function startFlushTimer() {
|
function startFlushTimer() {
|
||||||
if (flushTimer || !isConfigured) return;
|
if (!SLS_ENABLED || flushTimer || !isConfigured) return;
|
||||||
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
|
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +108,11 @@ function startFlushTimer() {
|
|||||||
* @param {string} [logData.error] - 错误信息
|
* @param {string} [logData.error] - 错误信息
|
||||||
*/
|
*/
|
||||||
export function logRequest(logData) {
|
export function logRequest(logData) {
|
||||||
const enrichedLog = {
|
if (!SLS_ENABLED) return;
|
||||||
|
const enrichedLog = sanitizeForLog({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...logData
|
...logData
|
||||||
};
|
});
|
||||||
|
|
||||||
// 始终输出到控制台
|
// 始终输出到控制台
|
||||||
console.log('[SLS]', JSON.stringify(enrichedLog));
|
console.log('[SLS]', JSON.stringify(enrichedLog));
|
||||||
@@ -112,6 +131,7 @@ export function logRequest(logData) {
|
|||||||
* 优雅关闭,刷新剩余日志
|
* 优雅关闭,刷新剩余日志
|
||||||
*/
|
*/
|
||||||
export async function shutdown() {
|
export async function shutdown() {
|
||||||
|
if (!SLS_ENABLED) return;
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearInterval(flushTimer);
|
clearInterval(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
|
|||||||
112
tests/refresh-client.test.js
Normal file
112
tests/refresh-client.test.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { requestRefreshToken } from '../refresh-client.js';
|
||||||
|
|
||||||
|
function mockResponse({ ok, status, jsonData, textData, headers }) {
|
||||||
|
const normalizedHeaders = {};
|
||||||
|
if (headers) {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
normalizedHeaders[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => jsonData ?? {},
|
||||||
|
text: async () => textData ?? '',
|
||||||
|
headers: {
|
||||||
|
get: (name) => normalizedHeaders[name.toLowerCase()] ?? null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refresh retries on 500 then succeeds', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
return mockResponse({ ok: false, status: 500, textData: 'boom' });
|
||||||
|
}
|
||||||
|
return mockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { access_token: 'access', refresh_token: 'refresh' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 20,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(data.access_token, 'access');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh does not retry on 400', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return mockResponse({ ok: false, status: 400, textData: 'bad request' });
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 20,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
}),
|
||||||
|
(err) => err?.status === 400
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh retries on timeout abort', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls += 1;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!options?.signal) {
|
||||||
|
resolve(mockResponse({ ok: true, status: 200, jsonData: {} }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
const error = new Error('Aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
const error = new Error('Aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 5,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
}),
|
||||||
|
(err) => err?.name === 'AbortError'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user