|
|
import axios from 'axios'; |
|
|
import { log } from '../utils/logger.js'; |
|
|
import { generateSessionId, generateProjectId } from '../utils/idGenerator.js'; |
|
|
import config, { getConfigJson } from '../config/config.js'; |
|
|
import { OAUTH_CONFIG } from '../constants/oauth.js'; |
|
|
import { buildAxiosRequestConfig } from '../utils/httpClient.js'; |
|
|
import { |
|
|
DEFAULT_REQUEST_COUNT_PER_TOKEN, |
|
|
TOKEN_REFRESH_BUFFER |
|
|
} from '../constants/index.js'; |
|
|
import TokenStore from './token_store.js'; |
|
|
import { TokenError } from '../utils/errors.js'; |
|
|
|
|
|
|
|
|
const RotationStrategy = { |
|
|
ROUND_ROBIN: 'round_robin', |
|
|
QUOTA_EXHAUSTED: 'quota_exhausted', |
|
|
REQUEST_COUNT: 'request_count' |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TokenManager { |
|
|
|
|
|
|
|
|
|
|
|
constructor(filePath) { |
|
|
this.store = new TokenStore(filePath); |
|
|
|
|
|
this.tokens = []; |
|
|
|
|
|
this.currentIndex = 0; |
|
|
|
|
|
|
|
|
|
|
|
this.rotationStrategy = RotationStrategy.ROUND_ROBIN; |
|
|
|
|
|
this.requestCountPerToken = DEFAULT_REQUEST_COUNT_PER_TOKEN; |
|
|
|
|
|
this.tokenRequestCounts = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
this.availableQuotaTokenIndices = []; |
|
|
|
|
|
this.currentQuotaIndex = 0; |
|
|
|
|
|
|
|
|
this._initPromise = null; |
|
|
} |
|
|
|
|
|
async _initialize() { |
|
|
try { |
|
|
log.info('正在初始化token管理器...'); |
|
|
const tokenArray = await this.store.readAll(); |
|
|
|
|
|
this.tokens = tokenArray.filter(token => token.enable !== false).map(token => ({ |
|
|
...token, |
|
|
sessionId: generateSessionId() |
|
|
})); |
|
|
|
|
|
this.currentIndex = 0; |
|
|
this.tokenRequestCounts.clear(); |
|
|
this._rebuildAvailableQuotaTokens(); |
|
|
|
|
|
|
|
|
this.loadRotationConfig(); |
|
|
|
|
|
if (this.tokens.length === 0) { |
|
|
log.warn('⚠ 暂无可用账号,请使用以下方式添加:'); |
|
|
log.warn(' 方式1: 运行 npm run login 命令登录'); |
|
|
log.warn(' 方式2: 访问前端管理页面添加账号'); |
|
|
} else { |
|
|
log.info(`成功加载 ${this.tokens.length} 个可用token`); |
|
|
if (this.rotationStrategy === RotationStrategy.REQUEST_COUNT) { |
|
|
log.info(`轮询策略: ${this.rotationStrategy}, 每token请求 ${this.requestCountPerToken} 次后切换`); |
|
|
} else { |
|
|
log.info(`轮询策略: ${this.rotationStrategy}`); |
|
|
} |
|
|
|
|
|
|
|
|
await this._refreshExpiredTokensConcurrently(); |
|
|
} |
|
|
} catch (error) { |
|
|
log.error('初始化token失败:', error.message); |
|
|
this.tokens = []; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async _refreshExpiredTokensConcurrently() { |
|
|
const expiredTokens = this.tokens.filter(token => this.isExpired(token)); |
|
|
if (expiredTokens.length === 0) { |
|
|
return; |
|
|
} |
|
|
|
|
|
log.info(`发现 ${expiredTokens.length} 个过期token,开始并发刷新...`); |
|
|
const startTime = Date.now(); |
|
|
|
|
|
const results = await Promise.allSettled( |
|
|
expiredTokens.map(token => this._refreshTokenSafe(token)) |
|
|
); |
|
|
|
|
|
let successCount = 0; |
|
|
let failCount = 0; |
|
|
const tokensToDisable = []; |
|
|
|
|
|
results.forEach((result, index) => { |
|
|
const token = expiredTokens[index]; |
|
|
if (result.status === 'fulfilled') { |
|
|
if (result.value === 'success') { |
|
|
successCount++; |
|
|
} else if (result.value === 'disable') { |
|
|
tokensToDisable.push(token); |
|
|
failCount++; |
|
|
} |
|
|
} else { |
|
|
failCount++; |
|
|
log.error(`...${token.access_token?.slice(-8) || 'unknown'} 刷新失败:`, result.reason?.message || result.reason); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
for (const token of tokensToDisable) { |
|
|
this.disableToken(token); |
|
|
} |
|
|
|
|
|
const elapsed = Date.now() - startTime; |
|
|
log.info(`并发刷新完成: 成功 ${successCount}, 失败 ${failCount}, 耗时 ${elapsed}ms`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async _refreshTokenSafe(token) { |
|
|
try { |
|
|
await this.refreshToken(token); |
|
|
return 'success'; |
|
|
} catch (error) { |
|
|
if (error.statusCode === 403 || error.statusCode === 400) { |
|
|
log.warn(`...${token.access_token?.slice(-8) || 'unknown'}: Token 已失效,将被禁用`); |
|
|
return 'disable'; |
|
|
} |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
async _ensureInitialized() { |
|
|
if (!this._initPromise) { |
|
|
this._initPromise = this._initialize(); |
|
|
} |
|
|
return this._initPromise; |
|
|
} |
|
|
|
|
|
|
|
|
loadRotationConfig() { |
|
|
try { |
|
|
const jsonConfig = getConfigJson(); |
|
|
if (jsonConfig.rotation) { |
|
|
this.rotationStrategy = jsonConfig.rotation.strategy || RotationStrategy.ROUND_ROBIN; |
|
|
this.requestCountPerToken = jsonConfig.rotation.requestCount || 10; |
|
|
} |
|
|
} catch (error) { |
|
|
log.warn('加载轮询配置失败,使用默认值:', error.message); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
updateRotationConfig(strategy, requestCount) { |
|
|
if (strategy && Object.values(RotationStrategy).includes(strategy)) { |
|
|
this.rotationStrategy = strategy; |
|
|
} |
|
|
if (requestCount && requestCount > 0) { |
|
|
this.requestCountPerToken = requestCount; |
|
|
} |
|
|
|
|
|
this.tokenRequestCounts.clear(); |
|
|
if (this.rotationStrategy === RotationStrategy.REQUEST_COUNT) { |
|
|
log.info(`轮询策略已更新: ${this.rotationStrategy}, 每token请求 ${this.requestCountPerToken} 次后切换`); |
|
|
} else { |
|
|
log.info(`轮询策略已更新: ${this.rotationStrategy}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_rebuildAvailableQuotaTokens() { |
|
|
this.availableQuotaTokenIndices = []; |
|
|
this.tokens.forEach((token, index) => { |
|
|
if (token.enable !== false && token.hasQuota !== false) { |
|
|
this.availableQuotaTokenIndices.push(index); |
|
|
} |
|
|
}); |
|
|
|
|
|
if (this.availableQuotaTokenIndices.length === 0) { |
|
|
this.currentQuotaIndex = 0; |
|
|
} else { |
|
|
this.currentQuotaIndex = this.currentQuotaIndex % this.availableQuotaTokenIndices.length; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_removeQuotaIndex(tokenIndex) { |
|
|
const pos = this.availableQuotaTokenIndices.indexOf(tokenIndex); |
|
|
if (pos !== -1) { |
|
|
this.availableQuotaTokenIndices.splice(pos, 1); |
|
|
if (this.currentQuotaIndex >= this.availableQuotaTokenIndices.length) { |
|
|
this.currentQuotaIndex = 0; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
async fetchProjectId(token) { |
|
|
const response = await axios(buildAxiosRequestConfig({ |
|
|
method: 'POST', |
|
|
url: 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist', |
|
|
headers: { |
|
|
'Host': 'daily-cloudcode-pa.sandbox.googleapis.com', |
|
|
'User-Agent': 'antigravity/1.11.9 windows/amd64', |
|
|
'Authorization': `Bearer ${token.access_token}`, |
|
|
'Content-Type': 'application/json', |
|
|
'Accept-Encoding': 'gzip' |
|
|
}, |
|
|
data: JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY' } }) |
|
|
})); |
|
|
return response.data?.cloudaicompanionProject; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
isExpired(token) { |
|
|
if (!token.timestamp || !token.expires_in) return true; |
|
|
const expiresAt = token.timestamp + (token.expires_in * 1000); |
|
|
return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER; |
|
|
} |
|
|
|
|
|
async refreshToken(token) { |
|
|
log.info('正在刷新token...'); |
|
|
const body = new URLSearchParams({ |
|
|
client_id: OAUTH_CONFIG.CLIENT_ID, |
|
|
client_secret: OAUTH_CONFIG.CLIENT_SECRET, |
|
|
grant_type: 'refresh_token', |
|
|
refresh_token: token.refresh_token |
|
|
}); |
|
|
|
|
|
try { |
|
|
const response = await axios(buildAxiosRequestConfig({ |
|
|
method: 'POST', |
|
|
url: OAUTH_CONFIG.TOKEN_URL, |
|
|
headers: { |
|
|
'Host': 'oauth2.googleapis.com', |
|
|
'User-Agent': 'Go-http-client/1.1', |
|
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
|
'Accept-Encoding': 'gzip' |
|
|
}, |
|
|
data: body.toString() |
|
|
})); |
|
|
|
|
|
token.access_token = response.data.access_token; |
|
|
token.expires_in = response.data.expires_in; |
|
|
token.timestamp = Date.now(); |
|
|
this.saveToFile(token); |
|
|
return token; |
|
|
} catch (error) { |
|
|
const statusCode = error.response?.status; |
|
|
const rawBody = error.response?.data; |
|
|
const suffix = token.access_token ? token.access_token.slice(-8) : null; |
|
|
const message = typeof rawBody === 'string' ? rawBody : (rawBody?.error?.message || error.message || '刷新 token 失败'); |
|
|
throw new TokenError(message, suffix, statusCode || 500); |
|
|
} |
|
|
} |
|
|
|
|
|
saveToFile(tokenToUpdate = null) { |
|
|
|
|
|
this.store.mergeActiveTokens(this.tokens, tokenToUpdate).catch((error) => { |
|
|
log.error('保存账号配置文件失败:', error.message); |
|
|
}); |
|
|
} |
|
|
|
|
|
disableToken(token) { |
|
|
log.warn(`禁用token ...${token.access_token.slice(-8)}`) |
|
|
token.enable = false; |
|
|
this.saveToFile(); |
|
|
this.tokens = this.tokens.filter(t => t.refresh_token !== token.refresh_token); |
|
|
this.currentIndex = this.currentIndex % Math.max(this.tokens.length, 1); |
|
|
|
|
|
this._rebuildAvailableQuotaTokens(); |
|
|
} |
|
|
|
|
|
|
|
|
incrementRequestCount(tokenKey) { |
|
|
const current = this.tokenRequestCounts.get(tokenKey) || 0; |
|
|
const newCount = current + 1; |
|
|
this.tokenRequestCounts.set(tokenKey, newCount); |
|
|
return newCount; |
|
|
} |
|
|
|
|
|
|
|
|
resetRequestCount(tokenKey) { |
|
|
this.tokenRequestCounts.set(tokenKey, 0); |
|
|
} |
|
|
|
|
|
|
|
|
shouldRotate(token) { |
|
|
switch (this.rotationStrategy) { |
|
|
case RotationStrategy.ROUND_ROBIN: |
|
|
|
|
|
return true; |
|
|
|
|
|
case RotationStrategy.QUOTA_EXHAUSTED: |
|
|
|
|
|
|
|
|
return token.hasQuota === false; |
|
|
|
|
|
case RotationStrategy.REQUEST_COUNT: |
|
|
|
|
|
const tokenKey = token.refresh_token; |
|
|
const count = this.incrementRequestCount(tokenKey); |
|
|
if (count >= this.requestCountPerToken) { |
|
|
this.resetRequestCount(tokenKey); |
|
|
return true; |
|
|
} |
|
|
return false; |
|
|
|
|
|
default: |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
markQuotaExhausted(token) { |
|
|
token.hasQuota = false; |
|
|
this.saveToFile(token); |
|
|
log.warn(`...${token.access_token.slice(-8)}: 额度已耗尽,标记为无额度`); |
|
|
|
|
|
if (this.rotationStrategy === RotationStrategy.QUOTA_EXHAUSTED) { |
|
|
const tokenIndex = this.tokens.findIndex(t => t.refresh_token === token.refresh_token); |
|
|
if (tokenIndex !== -1) { |
|
|
this._removeQuotaIndex(tokenIndex); |
|
|
} |
|
|
this.currentIndex = (this.currentIndex + 1) % Math.max(this.tokens.length, 1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
restoreQuota(token) { |
|
|
token.hasQuota = true; |
|
|
this.saveToFile(token); |
|
|
log.info(`...${token.access_token.slice(-8)}: 额度已恢复`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async _prepareToken(token) { |
|
|
|
|
|
if (this.isExpired(token)) { |
|
|
await this.refreshToken(token); |
|
|
} |
|
|
|
|
|
|
|
|
if (!token.projectId) { |
|
|
if (config.skipProjectIdFetch) { |
|
|
token.projectId = generateProjectId(); |
|
|
this.saveToFile(token); |
|
|
log.info(`...${token.access_token.slice(-8)}: 使用随机生成的projectId: ${token.projectId}`); |
|
|
} else { |
|
|
const projectId = await this.fetchProjectId(token); |
|
|
if (projectId === undefined) { |
|
|
log.warn(`...${token.access_token.slice(-8)}: 无资格获取projectId,禁用账号`); |
|
|
return 'disable'; |
|
|
} |
|
|
token.projectId = projectId; |
|
|
this.saveToFile(token); |
|
|
} |
|
|
} |
|
|
|
|
|
return 'ready'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_handleTokenError(error, token) { |
|
|
const suffix = token.access_token?.slice(-8) || 'unknown'; |
|
|
if (error.statusCode === 403 || error.statusCode === 400) { |
|
|
log.warn(`...${suffix}: Token 已失效或错误,已自动禁用该账号`); |
|
|
return 'disable'; |
|
|
} |
|
|
log.error(`...${suffix} 操作失败:`, error.message); |
|
|
return 'skip'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_resetAllQuotas() { |
|
|
log.warn('所有token额度已耗尽,重置额度状态'); |
|
|
this.tokens.forEach(t => { |
|
|
t.hasQuota = true; |
|
|
}); |
|
|
this.saveToFile(); |
|
|
this._rebuildAvailableQuotaTokens(); |
|
|
} |
|
|
|
|
|
async getToken() { |
|
|
await this._ensureInitialized(); |
|
|
if (this.tokens.length === 0) return null; |
|
|
|
|
|
|
|
|
if (this.rotationStrategy === RotationStrategy.QUOTA_EXHAUSTED) { |
|
|
return this._getTokenForQuotaExhaustedStrategy(); |
|
|
} |
|
|
|
|
|
return this._getTokenForDefaultStrategy(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async _getTokenForQuotaExhaustedStrategy() { |
|
|
|
|
|
if (this.availableQuotaTokenIndices.length === 0) { |
|
|
this._resetAllQuotas(); |
|
|
} |
|
|
|
|
|
const totalAvailable = this.availableQuotaTokenIndices.length; |
|
|
if (totalAvailable === 0) { |
|
|
return null; |
|
|
} |
|
|
|
|
|
const startIndex = this.currentQuotaIndex % totalAvailable; |
|
|
|
|
|
for (let i = 0; i < totalAvailable; i++) { |
|
|
const listIndex = (startIndex + i) % totalAvailable; |
|
|
const tokenIndex = this.availableQuotaTokenIndices[listIndex]; |
|
|
const token = this.tokens[tokenIndex]; |
|
|
|
|
|
try { |
|
|
const result = await this._prepareToken(token); |
|
|
if (result === 'disable') { |
|
|
this.disableToken(token); |
|
|
this._rebuildAvailableQuotaTokens(); |
|
|
if (this.tokens.length === 0 || this.availableQuotaTokenIndices.length === 0) { |
|
|
return null; |
|
|
} |
|
|
continue; |
|
|
} |
|
|
|
|
|
this.currentIndex = tokenIndex; |
|
|
this.currentQuotaIndex = listIndex; |
|
|
return token; |
|
|
} catch (error) { |
|
|
const action = this._handleTokenError(error, token); |
|
|
if (action === 'disable') { |
|
|
this.disableToken(token); |
|
|
this._rebuildAvailableQuotaTokens(); |
|
|
if (this.tokens.length === 0 || this.availableQuotaTokenIndices.length === 0) { |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
this._resetAllQuotas(); |
|
|
return this.tokens[0] || null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async _getTokenForDefaultStrategy() { |
|
|
const totalTokens = this.tokens.length; |
|
|
const startIndex = this.currentIndex; |
|
|
|
|
|
for (let i = 0; i < totalTokens; i++) { |
|
|
const index = (startIndex + i) % totalTokens; |
|
|
const token = this.tokens[index]; |
|
|
|
|
|
try { |
|
|
const result = await this._prepareToken(token); |
|
|
if (result === 'disable') { |
|
|
this.disableToken(token); |
|
|
if (this.tokens.length === 0) return null; |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
this.currentIndex = index; |
|
|
|
|
|
|
|
|
if (this.shouldRotate(token)) { |
|
|
this.currentIndex = (this.currentIndex + 1) % this.tokens.length; |
|
|
} |
|
|
|
|
|
return token; |
|
|
} catch (error) { |
|
|
const action = this._handleTokenError(error, token); |
|
|
if (action === 'disable') { |
|
|
this.disableToken(token); |
|
|
if (this.tokens.length === 0) return null; |
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
disableCurrentToken(token) { |
|
|
const found = this.tokens.find(t => t.access_token === token.access_token); |
|
|
if (found) { |
|
|
this.disableToken(found); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async reload() { |
|
|
this._initPromise = this._initialize(); |
|
|
await this._initPromise; |
|
|
log.info('Token已热重载'); |
|
|
} |
|
|
|
|
|
async addToken(tokenData) { |
|
|
try { |
|
|
const allTokens = await this.store.readAll(); |
|
|
|
|
|
const newToken = { |
|
|
access_token: tokenData.access_token, |
|
|
refresh_token: tokenData.refresh_token, |
|
|
expires_in: tokenData.expires_in || 3599, |
|
|
timestamp: tokenData.timestamp || Date.now(), |
|
|
enable: tokenData.enable !== undefined ? tokenData.enable : true |
|
|
}; |
|
|
|
|
|
if (tokenData.projectId) { |
|
|
newToken.projectId = tokenData.projectId; |
|
|
} |
|
|
if (tokenData.email) { |
|
|
newToken.email = tokenData.email; |
|
|
} |
|
|
if (tokenData.hasQuota !== undefined) { |
|
|
newToken.hasQuota = tokenData.hasQuota; |
|
|
} |
|
|
|
|
|
allTokens.push(newToken); |
|
|
await this.store.writeAll(allTokens); |
|
|
|
|
|
await this.reload(); |
|
|
return { success: true, message: 'Token添加成功' }; |
|
|
} catch (error) { |
|
|
log.error('添加Token失败:', error.message); |
|
|
return { success: false, message: error.message }; |
|
|
} |
|
|
} |
|
|
|
|
|
async updateToken(refreshToken, updates) { |
|
|
try { |
|
|
const allTokens = await this.store.readAll(); |
|
|
|
|
|
const index = allTokens.findIndex(t => t.refresh_token === refreshToken); |
|
|
if (index === -1) { |
|
|
return { success: false, message: 'Token不存在' }; |
|
|
} |
|
|
|
|
|
allTokens[index] = { ...allTokens[index], ...updates }; |
|
|
await this.store.writeAll(allTokens); |
|
|
|
|
|
await this.reload(); |
|
|
return { success: true, message: 'Token更新成功' }; |
|
|
} catch (error) { |
|
|
log.error('更新Token失败:', error.message); |
|
|
return { success: false, message: error.message }; |
|
|
} |
|
|
} |
|
|
|
|
|
async deleteToken(refreshToken) { |
|
|
try { |
|
|
const allTokens = await this.store.readAll(); |
|
|
|
|
|
const filteredTokens = allTokens.filter(t => t.refresh_token !== refreshToken); |
|
|
if (filteredTokens.length === allTokens.length) { |
|
|
return { success: false, message: 'Token不存在' }; |
|
|
} |
|
|
|
|
|
await this.store.writeAll(filteredTokens); |
|
|
|
|
|
await this.reload(); |
|
|
return { success: true, message: 'Token删除成功' }; |
|
|
} catch (error) { |
|
|
log.error('删除Token失败:', error.message); |
|
|
return { success: false, message: error.message }; |
|
|
} |
|
|
} |
|
|
|
|
|
async getTokenList() { |
|
|
try { |
|
|
const allTokens = await this.store.readAll(); |
|
|
|
|
|
return allTokens.map(token => ({ |
|
|
refresh_token: token.refresh_token, |
|
|
access_token: token.access_token, |
|
|
access_token_suffix: token.access_token ? `...${token.access_token.slice(-8)}` : 'N/A', |
|
|
expires_in: token.expires_in, |
|
|
timestamp: token.timestamp, |
|
|
enable: token.enable !== false, |
|
|
projectId: token.projectId || null, |
|
|
email: token.email || null, |
|
|
hasQuota: token.hasQuota !== false |
|
|
})); |
|
|
} catch (error) { |
|
|
log.error('获取Token列表失败:', error.message); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
getRotationConfig() { |
|
|
return { |
|
|
strategy: this.rotationStrategy, |
|
|
requestCount: this.requestCountPerToken, |
|
|
currentIndex: this.currentIndex, |
|
|
tokenCounts: Object.fromEntries(this.tokenRequestCounts) |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
export { RotationStrategy }; |
|
|
|
|
|
const tokenManager = new TokenManager(); |
|
|
export default tokenManager; |
|
|
|