From 2fc84a6acaa6217ba558aba8d9aa3644bd008f8c Mon Sep 17 00:00:00 2001
From: shaw
Date: Thu, 9 Oct 2025 23:05:09 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EDroid=20cli=E6=94=AF?=
=?UTF-8?q?=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 2 +-
src/app.js | 3 +
src/models/redis.js | 29 +
src/routes/admin.js | 217 +++++
src/routes/droidRoutes.js | 135 ++++
src/services/droidAccountService.js | 761 ++++++++++++++++++
src/services/droidRelayService.js | 743 +++++++++++++++++
src/utils/tokenMask.js | 12 +-
src/utils/workosOAuthHelper.js | 170 ++++
.../src/components/accounts/AccountForm.vue | 188 ++++-
.../src/components/accounts/OAuthFlow.vue | 319 +++++++-
web/admin-spa/src/stores/accounts.js | 98 ++-
web/admin-spa/src/views/AccountsView.vue | 93 ++-
13 files changed, 2734 insertions(+), 36 deletions(-)
create mode 100644 src/routes/droidRoutes.js
create mode 100644 src/services/droidAccountService.js
create mode 100644 src/services/droidRelayService.js
create mode 100644 src/utils/workosOAuthHelper.js
diff --git a/package.json b/package.json
index 79fa3e23..72ea4720 100644
--- a/package.json
+++ b/package.json
@@ -69,10 +69,10 @@
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",
+ "string-similarity": "^4.0.4",
"table": "^6.8.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
- "string-similarity": "^4.0.4",
"winston-daily-rotate-file": "^4.7.1"
},
"devDependencies": {
diff --git a/src/app.js b/src/app.js
index f0bdadb8..13d4d331 100644
--- a/src/app.js
+++ b/src/app.js
@@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes')
+const droidRoutes = require('./routes/droidRoutes')
const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook')
@@ -262,6 +263,8 @@ class Application {
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', openaiRoutes)
+ // Droid 路由:支持多种 Factory.ai 端点
+ this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
this.app.use('/azure', azureOpenaiRoutes)
this.app.use('/admin/webhook', webhookRoutes)
diff --git a/src/models/redis.js b/src/models/redis.js
index 602d0bca..e90f2d4a 100644
--- a/src/models/redis.js
+++ b/src/models/redis.js
@@ -1066,6 +1066,35 @@ class RedisClient {
const key = `claude:account:${accountId}`
return await this.client.del(key)
}
+
+ // 🤖 Droid 账户相关操作
+ async setDroidAccount(accountId, accountData) {
+ const key = `droid:account:${accountId}`
+ await this.client.hset(key, accountData)
+ }
+
+ async getDroidAccount(accountId) {
+ const key = `droid:account:${accountId}`
+ return await this.client.hgetall(key)
+ }
+
+ async getAllDroidAccounts() {
+ const keys = await this.client.keys('droid:account:*')
+ const accounts = []
+ for (const key of keys) {
+ const accountData = await this.client.hgetall(key)
+ if (accountData && Object.keys(accountData).length > 0) {
+ accounts.push({ id: key.replace('droid:account:', ''), ...accountData })
+ }
+ }
+ return accounts
+ }
+
+ async deleteDroidAccount(accountId) {
+ const key = `droid:account:${accountId}`
+ return await this.client.del(key)
+ }
+
async setOpenAiAccount(accountId, accountData) {
const key = `openai:account:${accountId}`
await this.client.hset(key, accountData)
diff --git a/src/routes/admin.js b/src/routes/admin.js
index f55d092d..119d457d 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
const bedrockAccountService = require('../services/bedrockAccountService')
const ccrAccountService = require('../services/ccrAccountService')
const geminiAccountService = require('../services/geminiAccountService')
+const droidAccountService = require('../services/droidAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
@@ -13,6 +14,11 @@ const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth')
const logger = require('../utils/logger')
const oauthHelper = require('../utils/oauthHelper')
+const {
+ startDeviceAuthorization,
+ pollDeviceAuthorization,
+ WorkOSDeviceAuthError
+} = require('../utils/workosOAuthHelper')
const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
@@ -8357,4 +8363,215 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
}
})
+// 🤖 Droid 账户管理
+
+// 生成 Droid OAuth 授权链接
+router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
+ try {
+ const { proxy } = req.body || {}
+ const deviceAuth = await startDeviceAuthorization(proxy || null)
+
+ const sessionId = crypto.randomUUID()
+ const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
+
+ await redis.setOAuthSession(sessionId, {
+ deviceCode: deviceAuth.deviceCode,
+ userCode: deviceAuth.userCode,
+ verificationUri: deviceAuth.verificationUri,
+ verificationUriComplete: deviceAuth.verificationUriComplete,
+ interval: deviceAuth.interval,
+ proxy: proxy || null,
+ createdAt: new Date().toISOString(),
+ expiresAt
+ })
+
+ logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
+ return res.json({
+ success: true,
+ data: {
+ sessionId,
+ userCode: deviceAuth.userCode,
+ verificationUri: deviceAuth.verificationUri,
+ verificationUriComplete: deviceAuth.verificationUriComplete,
+ expiresIn: deviceAuth.expiresIn,
+ interval: deviceAuth.interval,
+ instructions: [
+ '1. 使用下方验证码进入授权页面并确认访问权限。',
+ '2. 在授权页面登录 Factory / Droid 账户并点击允许。',
+ '3. 回到此处点击“完成授权”完成凭证获取。'
+ ]
+ }
+ })
+ } catch (error) {
+ const message =
+ error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
+ logger.error('❌ 生成 Droid 设备码授权失败:', message)
+ return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
+ }
+})
+
+// 交换 Droid 授权码
+router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
+ const { sessionId, proxy } = req.body || {}
+ try {
+ if (!sessionId) {
+ return res.status(400).json({ error: 'Session ID is required' })
+ }
+
+ const oauthSession = await redis.getOAuthSession(sessionId)
+ if (!oauthSession) {
+ return res.status(400).json({ error: 'Invalid or expired OAuth session' })
+ }
+
+ if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
+ await redis.deleteOAuthSession(sessionId)
+ return res
+ .status(400)
+ .json({ error: 'OAuth session has expired, please generate a new authorization URL' })
+ }
+
+ if (!oauthSession.deviceCode) {
+ await redis.deleteOAuthSession(sessionId)
+ return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
+ }
+
+ const proxyConfig = proxy || oauthSession.proxy || null
+ const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
+
+ await redis.deleteOAuthSession(sessionId)
+
+ logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
+ return res.json({ success: true, data: { tokens } })
+ } catch (error) {
+ if (error instanceof WorkOSDeviceAuthError) {
+ if (error.code === 'authorization_pending' || error.code === 'slow_down') {
+ const oauthSession = await redis.getOAuthSession(sessionId)
+ const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
+ const remainingSeconds =
+ expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
+ ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
+ : null
+
+ return res.json({
+ success: false,
+ pending: true,
+ error: error.code,
+ message: error.message,
+ retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
+ expiresIn: remainingSeconds
+ })
+ }
+
+ if (error.code === 'expired_token') {
+ await redis.deleteOAuthSession(sessionId)
+ return res.status(400).json({
+ error: 'Device code expired',
+ message: '授权已过期,请重新生成设备码并再次授权'
+ })
+ }
+
+ logger.error('❌ Droid 授权失败:', error.message)
+ return res.status(500).json({
+ error: 'Failed to exchange Droid authorization code',
+ message: error.message,
+ errorCode: error.code
+ })
+ }
+
+ logger.error('❌ 交换 Droid 授权码失败:', error)
+ return res.status(500).json({
+ error: 'Failed to exchange Droid authorization code',
+ message: error.message
+ })
+ }
+})
+
+// 获取所有 Droid 账户
+router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const accounts = await droidAccountService.getAllAccounts()
+
+ // 添加使用统计
+ const accountsWithStats = await Promise.all(
+ accounts.map(async (account) => {
+ try {
+ const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
+ return {
+ ...account,
+ schedulable: account.schedulable === 'true',
+ usage: {
+ daily: usageStats.daily,
+ total: usageStats.total,
+ averages: usageStats.averages
+ }
+ }
+ } catch (error) {
+ logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
+ return {
+ ...account,
+ usage: {
+ daily: { tokens: 0, requests: 0 },
+ total: { tokens: 0, requests: 0 },
+ averages: { rpm: 0, tpm: 0 }
+ }
+ }
+ }
+ })
+ )
+
+ return res.json({ success: true, data: accountsWithStats })
+ } catch (error) {
+ logger.error('Failed to get Droid accounts:', error)
+ return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
+ }
+})
+
+// 创建 Droid 账户
+router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const account = await droidAccountService.createAccount(req.body)
+ logger.success(`Created Droid account: ${account.name} (${account.id})`)
+ return res.json({ success: true, data: account })
+ } catch (error) {
+ logger.error('Failed to create Droid account:', error)
+ return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
+ }
+})
+
+// 更新 Droid 账户
+router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
+ try {
+ const { id } = req.params
+ const account = await droidAccountService.updateAccount(id, req.body)
+ return res.json({ success: true, data: account })
+ } catch (error) {
+ logger.error(`Failed to update Droid account ${req.params.id}:`, error)
+ return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
+ }
+})
+
+// 删除 Droid 账户
+router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
+ try {
+ const { id } = req.params
+ await droidAccountService.deleteAccount(id)
+ return res.json({ success: true, message: 'Droid account deleted successfully' })
+ } catch (error) {
+ logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
+ return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
+ }
+})
+
+// 刷新 Droid 账户 token
+router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
+ try {
+ const { id } = req.params
+ const result = await droidAccountService.refreshAccessToken(id)
+ return res.json({ success: true, data: result })
+ } catch (error) {
+ logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
+ return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
+ }
+})
+
module.exports = router
diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js
new file mode 100644
index 00000000..1a0dc014
--- /dev/null
+++ b/src/routes/droidRoutes.js
@@ -0,0 +1,135 @@
+const express = require('express')
+const { authenticateApiKey } = require('../middleware/auth')
+const droidRelayService = require('../services/droidRelayService')
+const logger = require('../utils/logger')
+
+const router = express.Router()
+
+/**
+ * Droid API 转发路由
+ *
+ * 支持多种 Factory.ai 端点:
+ * - /droid/claude - Anthropic (Claude) Messages API
+ * - /droid/openai - OpenAI Responses API
+ * - /droid/chat - OpenAI Chat Completions API (通用)
+ */
+
+// Claude (Anthropic) 端点 - /v1/messages
+router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
+ try {
+ const result = await droidRelayService.relayRequest(
+ req.body,
+ req.apiKey,
+ req,
+ res,
+ req.headers,
+ { endpointType: 'anthropic' }
+ )
+
+ // 如果是流式响应,已经在 relayService 中处理了
+ if (result.streaming) {
+ return
+ }
+
+ // 非流式响应
+ res.status(result.statusCode).set(result.headers).send(result.body)
+ } catch (error) {
+ logger.error('Droid Claude relay error:', error)
+ res.status(500).json({
+ error: 'internal_server_error',
+ message: error.message
+ })
+ }
+})
+
+// OpenAI 端点 - /v1/responses
+router.post('/openai/v1/responses', authenticateApiKey, async (req, res) => {
+ try {
+ const result = await droidRelayService.relayRequest(
+ req.body,
+ req.apiKey,
+ req,
+ res,
+ req.headers,
+ { endpointType: 'openai' }
+ )
+
+ if (result.streaming) {
+ return
+ }
+
+ res.status(result.statusCode).set(result.headers).send(result.body)
+ } catch (error) {
+ logger.error('Droid OpenAI relay error:', error)
+ res.status(500).json({
+ error: 'internal_server_error',
+ message: error.message
+ })
+ }
+})
+
+// 通用 OpenAI Chat Completions 端点
+router.post('/chat/v1/chat/completions', authenticateApiKey, async (req, res) => {
+ try {
+ const result = await droidRelayService.relayRequest(
+ req.body,
+ req.apiKey,
+ req,
+ res,
+ req.headers,
+ { endpointType: 'common' }
+ )
+
+ if (result.streaming) {
+ return
+ }
+
+ res.status(result.statusCode).set(result.headers).send(result.body)
+ } catch (error) {
+ logger.error('Droid Chat relay error:', error)
+ res.status(500).json({
+ error: 'internal_server_error',
+ message: error.message
+ })
+ }
+})
+
+// 模型列表端点(兼容性)
+router.get('/*/v1/models', authenticateApiKey, async (req, res) => {
+ try {
+ // 返回可用的模型列表
+ const models = [
+ {
+ id: 'claude-opus-4-1-20250805',
+ object: 'model',
+ created: Date.now(),
+ owned_by: 'anthropic'
+ },
+ {
+ id: 'claude-sonnet-4-5-20250929',
+ object: 'model',
+ created: Date.now(),
+ owned_by: 'anthropic'
+ },
+ {
+ id: 'gpt-5-2025-08-07',
+ object: 'model',
+ created: Date.now(),
+ owned_by: 'openai'
+ }
+ ]
+
+ res.json({
+ object: 'list',
+ data: models
+ })
+ } catch (error) {
+ logger.error('Droid models list error:', error)
+ res.status(500).json({
+ error: 'internal_server_error',
+ message: error.message
+ })
+ }
+})
+
+module.exports = router
diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js
new file mode 100644
index 00000000..3d0c743a
--- /dev/null
+++ b/src/services/droidAccountService.js
@@ -0,0 +1,761 @@
+const { v4: uuidv4 } = require('uuid')
+const crypto = require('crypto')
+const axios = require('axios')
+const redis = require('../models/redis')
+const config = require('../../config/config')
+const logger = require('../utils/logger')
+const { maskToken } = require('../utils/tokenMask')
+const ProxyHelper = require('../utils/proxyHelper')
+const LRUCache = require('../utils/lruCache')
+
+/**
+ * Droid 账户管理服务
+ *
+ * 支持 WorkOS OAuth 集成,管理 Droid (Factory.ai) 账户
+ * 提供账户创建、token 刷新、代理配置等功能
+ */
+class DroidAccountService {
+ constructor() {
+ // WorkOS OAuth 配置
+ this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate'
+ this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm'
+
+ this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB'
+
+ // Token 刷新策略
+ this.refreshIntervalHours = 6 // 每6小时刷新一次
+ this.tokenValidHours = 8 // Token 有效期8小时
+
+ // 加密相关常量
+ this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
+ this.ENCRYPTION_SALT = 'droid-account-salt'
+
+ // 🚀 性能优化:缓存派生的加密密钥
+ this._encryptionKeyCache = null
+
+ // 🔄 解密结果缓存
+ this._decryptCache = new LRUCache(500)
+
+ // 🧹 定期清理缓存(每10分钟)
+ setInterval(
+ () => {
+ this._decryptCache.cleanup()
+ logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
+ },
+ 10 * 60 * 1000
+ )
+ }
+
+ /**
+ * 生成加密密钥(缓存优化)
+ */
+ _generateEncryptionKey() {
+ if (!this._encryptionKeyCache) {
+ this._encryptionKeyCache = crypto.scryptSync(
+ config.security.encryptionKey,
+ this.ENCRYPTION_SALT,
+ 32
+ )
+ logger.info('🔑 Droid encryption key derived and cached for performance optimization')
+ }
+ return this._encryptionKeyCache
+ }
+
+ /**
+ * 加密敏感数据
+ */
+ _encryptSensitiveData(text) {
+ if (!text) {
+ return ''
+ }
+
+ const key = this._generateEncryptionKey()
+ const iv = crypto.randomBytes(16)
+ const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
+
+ let encrypted = cipher.update(text, 'utf8', 'hex')
+ encrypted += cipher.final('hex')
+
+ return `${iv.toString('hex')}:${encrypted}`
+ }
+
+ /**
+ * 解密敏感数据(带缓存)
+ */
+ _decryptSensitiveData(encryptedText) {
+ if (!encryptedText) {
+ return ''
+ }
+
+ // 🎯 检查缓存
+ const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
+ const cached = this._decryptCache.get(cacheKey)
+ if (cached !== undefined) {
+ return cached
+ }
+
+ try {
+ const key = this._generateEncryptionKey()
+ const parts = encryptedText.split(':')
+ const iv = Buffer.from(parts[0], 'hex')
+ const encrypted = parts[1]
+
+ const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
+ decrypted += decipher.final('utf8')
+
+ // 💾 存入缓存(5分钟过期)
+ this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
+
+ return decrypted
+ } catch (error) {
+ logger.error('❌ Failed to decrypt Droid data:', error)
+ return ''
+ }
+ }
+
+ /**
+ * 使用 WorkOS Refresh Token 刷新并验证凭证
+ */
+ async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null) {
+ if (!refreshToken || typeof refreshToken !== 'string') {
+ throw new Error('Refresh Token 无效')
+ }
+
+ const formData = new URLSearchParams()
+ formData.append('grant_type', 'refresh_token')
+ formData.append('refresh_token', refreshToken)
+ formData.append('client_id', this.workosClientId)
+
+ const requestOptions = {
+ method: 'POST',
+ url: this.oauthTokenUrl,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ data: formData.toString(),
+ timeout: 30000
+ }
+
+ if (proxyConfig) {
+ const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ if (proxyAgent) {
+ requestOptions.httpAgent = proxyAgent
+ requestOptions.httpsAgent = proxyAgent
+ logger.info(
+ `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}`
+ )
+ }
+ }
+
+ const response = await axios(requestOptions)
+ if (!response.data || !response.data.access_token) {
+ throw new Error('WorkOS OAuth 返回数据无效')
+ }
+
+ const {
+ access_token,
+ refresh_token,
+ user,
+ organization_id,
+ expires_in,
+ token_type,
+ authentication_method
+ } = response.data
+
+ let expiresAt = response.data.expires_at || ''
+ if (!expiresAt) {
+ const expiresInSeconds =
+ typeof expires_in === 'number' && Number.isFinite(expires_in)
+ ? expires_in
+ : this.tokenValidHours * 3600
+ expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString()
+ }
+
+ return {
+ accessToken: access_token,
+ refreshToken: refresh_token || refreshToken,
+ expiresAt,
+ expiresIn: typeof expires_in === 'number' && Number.isFinite(expires_in) ? expires_in : null,
+ user: user || null,
+ organizationId: organization_id || '',
+ tokenType: token_type || 'Bearer',
+ authenticationMethod: authentication_method || ''
+ }
+ }
+
+ /**
+ * 创建 Droid 账户
+ *
+ * @param {Object} options - 账户配置选项
+ * @returns {Promise
-
+
@@ -1539,7 +1560,12 @@
- 系统将使用 Refresh Token 自动获取 Access Token 和用户信息
+
+ 系统将使用 Refresh Token 自动获取 Access Token 和用户信息
+
+
+ 系统将使用 Refresh Token 自动刷新 Factory.ai 访问令牌,确保账户保持可用。
+
@@ -2769,6 +2795,8 @@ const determinePlatformGroup = (platform) => {
return 'openai'
} else if (platform === 'gemini') {
return 'gemini'
+ } else if (platform === 'droid') {
+ return 'droid'
}
return ''
}
@@ -2822,6 +2850,7 @@ const form = ref({
apiUrl: props.account?.apiUrl || '',
apiKey: props.account?.apiKey || '',
priority: props.account?.priority || 50,
+ endpointType: props.account?.endpointType || 'anthropic',
// OpenAI-Responses 特定字段
baseApi: props.account?.baseApi || '',
rateLimitDuration: props.account?.rateLimitDuration || 60,
@@ -3155,7 +3184,9 @@ const handleOAuthSuccess = async (tokenInfo) => {
: null
}
- if (form.value.platform === 'claude') {
+ const currentPlatform = form.value.platform
+
+ if (currentPlatform === 'claude') {
// Claude使用claudeAiOauth字段
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
data.priority = form.value.priority || 50
@@ -3170,7 +3201,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true // 标记为手动设置
}
- } else if (form.value.platform === 'gemini') {
+ } else if (currentPlatform === 'gemini') {
// Gemini使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo
if (form.value.projectId) {
@@ -3178,17 +3209,85 @@ const handleOAuthSuccess = async (tokenInfo) => {
}
// 添加 Gemini 优先级
data.priority = form.value.priority || 50
- } else if (form.value.platform === 'openai') {
+ } else if (currentPlatform === 'openai') {
data.openaiOauth = tokenInfo.tokens || tokenInfo
data.accountInfo = tokenInfo.accountInfo
data.priority = form.value.priority || 50
+ } else if (currentPlatform === 'droid') {
+ const rawTokens = tokenInfo.tokens || tokenInfo || {}
+
+ const normalizedTokens = {
+ accessToken: rawTokens.accessToken || rawTokens.access_token || '',
+ refreshToken: rawTokens.refreshToken || rawTokens.refresh_token || '',
+ expiresAt: rawTokens.expiresAt || rawTokens.expires_at || '',
+ expiresIn: rawTokens.expiresIn || rawTokens.expires_in || null,
+ tokenType: rawTokens.tokenType || rawTokens.token_type || 'Bearer',
+ organizationId: rawTokens.organizationId || rawTokens.organization_id || '',
+ authenticationMethod:
+ rawTokens.authenticationMethod || rawTokens.authentication_method || ''
+ }
+
+ if (!normalizedTokens.refreshToken) {
+ loading.value = false
+ showToast('授权成功但未返回 Refresh Token,请确认已授予离线访问权限后重试。', 'error')
+ return
+ }
+
+ data.refreshToken = normalizedTokens.refreshToken
+ data.accessToken = normalizedTokens.accessToken
+ data.expiresAt = normalizedTokens.expiresAt
+ if (normalizedTokens.expiresIn !== null && normalizedTokens.expiresIn !== undefined) {
+ data.expiresIn = normalizedTokens.expiresIn
+ }
+ data.priority = form.value.priority || 50
+ data.endpointType = form.value.endpointType || 'anthropic'
+ data.platform = 'droid'
+ data.tokenType = normalizedTokens.tokenType
+ data.authenticationMethod = normalizedTokens.authenticationMethod
+
+ if (normalizedTokens.organizationId) {
+ data.organizationId = normalizedTokens.organizationId
+ }
+
+ if (rawTokens.user) {
+ const user = rawTokens.user
+ const nameParts = []
+ if (typeof user.first_name === 'string' && user.first_name.trim()) {
+ nameParts.push(user.first_name.trim())
+ }
+ if (typeof user.last_name === 'string' && user.last_name.trim()) {
+ nameParts.push(user.last_name.trim())
+ }
+ const derivedName =
+ nameParts.join(' ').trim() ||
+ (typeof user.name === 'string' ? user.name.trim() : '') ||
+ (typeof user.display_name === 'string' ? user.display_name.trim() : '')
+
+ if (typeof user.email === 'string' && user.email.trim()) {
+ data.ownerEmail = user.email.trim()
+ }
+ if (derivedName) {
+ data.ownerName = derivedName
+ data.ownerDisplayName = derivedName
+ } else if (data.ownerEmail) {
+ data.ownerName = data.ownerName || data.ownerEmail
+ data.ownerDisplayName = data.ownerDisplayName || data.ownerEmail
+ }
+ if (typeof user.id === 'string' && user.id.trim()) {
+ data.userId = user.id.trim()
+ }
+ }
}
let result
- if (form.value.platform === 'claude') {
+ if (currentPlatform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
- } else if (form.value.platform === 'openai') {
+ } else if (currentPlatform === 'gemini') {
+ result = await accountsStore.createGeminiAccount(data)
+ } else if (currentPlatform === 'openai') {
result = await accountsStore.createOpenAIAccount(data)
+ } else if (currentPlatform === 'droid') {
+ result = await accountsStore.createDroidAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
@@ -3227,6 +3326,7 @@ const createAccount = async () => {
// 清除之前的错误
errors.value.name = ''
errors.value.accessToken = ''
+ errors.value.refreshToken = ''
errors.value.apiUrl = ''
errors.value.apiKey = ''
@@ -3314,6 +3414,15 @@ const createAccount = async () => {
errors.value.accessToken = '请填写 Access Token'
hasError = true
}
+ } else if (form.value.platform === 'droid') {
+ if (!form.value.accessToken || form.value.accessToken.trim() === '') {
+ errors.value.accessToken = '请填写 Access Token'
+ hasError = true
+ }
+ if (!form.value.refreshToken || form.value.refreshToken.trim() === '') {
+ errors.value.refreshToken = '请填写 Refresh Token'
+ hasError = true
+ }
} else if (form.value.platform === 'claude') {
// Claude 平台需要 Access Token
if (!form.value.accessToken || form.value.accessToken.trim() === '') {
@@ -3443,6 +3552,20 @@ const createAccount = async () => {
data.needsImmediateRefresh = true
data.requireRefreshSuccess = true // 必须刷新成功才能创建账户
data.priority = form.value.priority || 50
+ } else if (form.value.platform === 'droid') {
+ const accessToken = form.value.accessToken?.trim() || ''
+ const refreshToken = form.value.refreshToken?.trim() || ''
+ const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
+
+ data.accessToken = accessToken
+ data.refreshToken = refreshToken
+ data.expiresAt = expiresAt
+ data.expiresIn = 8 * 60 * 60
+ data.priority = form.value.priority || 50
+ data.endpointType = form.value.endpointType || 'anthropic'
+ data.platform = 'droid'
+ data.tokenType = 'Bearer'
+ data.authenticationMethod = 'manual'
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') {
// Claude Console 和 CCR 账户特定数据(CCR 使用 Claude Console 的后端逻辑)
data.apiUrl = form.value.apiUrl
@@ -3497,6 +3620,8 @@ const createAccount = async () => {
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') {
// CCR 使用 Claude Console 的后端 API
result = await accountsStore.createClaudeConsoleAccount(data)
+ } else if (form.value.platform === 'droid') {
+ result = await accountsStore.createDroidAccount(data)
} else if (form.value.platform === 'openai-responses') {
result = await accountsStore.createOpenAIResponsesAccount(data)
} else if (form.value.platform === 'bedrock') {
@@ -3606,6 +3731,9 @@ const updateAccount = async () => {
// 只有非空时才更新token
if (form.value.accessToken || form.value.refreshToken) {
+ const trimmedAccessToken = form.value.accessToken?.trim() || ''
+ const trimmedRefreshToken = form.value.refreshToken?.trim() || ''
+
if (props.account.platform === 'claude') {
// Claude需要构建claudeAiOauth对象
const expiresInMs = form.value.refreshToken
@@ -3613,8 +3741,8 @@ const updateAccount = async () => {
: 365 * 24 * 60 * 60 * 1000 // 1年
data.claudeAiOauth = {
- accessToken: form.value.accessToken || '',
- refreshToken: form.value.refreshToken || '',
+ accessToken: trimmedAccessToken || '',
+ refreshToken: trimmedRefreshToken || '',
expiresAt: Date.now() + expiresInMs,
scopes: props.account.scopes || [] // 保持原有的 scopes,如果没有则为空数组
}
@@ -3625,8 +3753,8 @@ const updateAccount = async () => {
: 365 * 24 * 60 * 60 * 1000 // 1年
data.geminiOauth = {
- access_token: form.value.accessToken || '',
- refresh_token: form.value.refreshToken || '',
+ access_token: trimmedAccessToken || '',
+ refresh_token: trimmedRefreshToken || '',
scope: 'https://www.googleapis.com/auth/cloud-platform',
token_type: 'Bearer',
expiry_date: Date.now() + expiresInMs
@@ -3639,16 +3767,23 @@ const updateAccount = async () => {
data.openaiOauth = {
idToken: '', // 不需要用户输入
- accessToken: form.value.accessToken || '',
- refreshToken: form.value.refreshToken || '',
+ accessToken: trimmedAccessToken || '',
+ refreshToken: trimmedRefreshToken || '',
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
// 编辑 OpenAI 账户时,如果更新了 Refresh Token,也需要验证
- if (form.value.refreshToken && form.value.refreshToken !== props.account.refreshToken) {
+ if (trimmedRefreshToken && trimmedRefreshToken !== props.account.refreshToken) {
data.needsImmediateRefresh = true
data.requireRefreshSuccess = true
}
+ } else if (props.account.platform === 'droid') {
+ if (trimmedAccessToken) {
+ data.accessToken = trimmedAccessToken
+ }
+ if (trimmedRefreshToken) {
+ data.refreshToken = trimmedRefreshToken
+ }
}
}
@@ -3656,6 +3791,11 @@ const updateAccount = async () => {
data.projectId = form.value.projectId || ''
}
+ if (props.account.platform === 'droid') {
+ data.priority = form.value.priority || 50
+ data.endpointType = form.value.endpointType || 'anthropic'
+ }
+
// Claude 官方账号优先级和订阅类型更新
if (props.account.platform === 'claude') {
// 更新模式也需要确保生成客户端ID
@@ -3771,6 +3911,8 @@ const updateAccount = async () => {
await accountsStore.updateAzureOpenAIAccount(props.account.id, data)
} else if (props.account.platform === 'gemini') {
await accountsStore.updateGeminiAccount(props.account.id, data)
+ } else if (props.account.platform === 'droid') {
+ await accountsStore.updateDroidAccount(props.account.id, data)
} else {
throw new Error(`不支持的平台: ${props.account.platform}`)
}
@@ -3824,6 +3966,16 @@ watch(
}
)
+// 监听Refresh Token变化,清除错误
+watch(
+ () => form.value.refreshToken,
+ () => {
+ if (errors.value.refreshToken && form.value.refreshToken?.trim()) {
+ errors.value.refreshToken = ''
+ }
+ }
+)
+
// 监听API URL变化,清除错误
watch(
() => form.value.apiUrl,
diff --git a/web/admin-spa/src/components/accounts/OAuthFlow.vue b/web/admin-spa/src/components/accounts/OAuthFlow.vue
index cecf6a97..ae6e9f20 100644
--- a/web/admin-spa/src/components/accounts/OAuthFlow.vue
+++ b/web/admin-spa/src/components/accounts/OAuthFlow.vue
@@ -464,6 +464,170 @@
+
+
+
+
+
+
+
+
+
Droid 账户授权
+
+ 请按照以下步骤完成 Factory (Droid) 账户的授权:
+
+
+
+
+
+
+
+ 1
+
+
+
+ 点击下方按钮生成授权链接
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ userCode || '------' }}
+
+
+
+
+
+
+
+ 剩余有效期:{{ formattedCountdown }}
+
+
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ 在浏览器中打开链接并完成授权
+
+
+
+ 在浏览器中打开授权页面,输入上方验证码并登录 Factory / Droid
+ 账户,最后点击允许授权。
+
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
+ 完成授权后点击下方“完成授权”按钮,系统会自动获取访问令牌。
+
+
+ 若提示授权仍在等待确认,请稍候片刻后系统会自动重试。
+
+
+
+
+
+
+
+
+
+
+
+
+ Droid
+
+ OAuth
+
@@ -1098,7 +1110,9 @@
? 'bg-gradient-to-br from-gray-600 to-gray-700'
: account.platform === 'ccr'
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
- : 'bg-gradient-to-br from-blue-500 to-blue-600'
+ : account.platform === 'droid'
+ ? 'bg-gradient-to-br from-cyan-500 to-sky-600'
+ : 'bg-gradient-to-br from-blue-500 to-blue-600'
]"
>
@@ -1712,7 +1728,8 @@ const platformOptions = ref([
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
- { value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
+ { value: 'ccr', label: 'CCR', icon: 'fa-code-branch' },
+ { value: 'droid', label: 'Droid', icon: 'fa-robot' }
])
const groupOptions = computed(() => {
@@ -2034,7 +2051,8 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/openai-accounts', { params }),
apiClient.get('/admin/azure-openai-accounts', { params }),
apiClient.get('/admin/openai-responses-accounts', { params }),
- apiClient.get('/admin/ccr-accounts', { params })
+ apiClient.get('/admin/ccr-accounts', { params }),
+ apiClient.get('/admin/droid-accounts', { params })
)
} else {
// 只请求指定平台,其他平台设为null占位
@@ -2047,7 +2065,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'claude-console':
@@ -2058,7 +2078,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'bedrock':
@@ -2069,7 +2091,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'gemini':
@@ -2080,7 +2104,9 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/gemini-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'openai':
@@ -2091,7 +2117,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
apiClient.get('/admin/openai-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'azure_openai':
@@ -2102,7 +2130,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
apiClient.get('/admin/azure-openai-accounts', { params }),
- Promise.resolve({ success: true, data: [] }) // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'openai-responses':
@@ -2113,7 +2143,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
- apiClient.get('/admin/openai-responses-accounts', { params })
+ apiClient.get('/admin/openai-responses-accounts', { params }),
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'ccr':
@@ -2124,7 +2156,22 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure 占位
- apiClient.get('/admin/ccr-accounts', { params })
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ apiClient.get('/admin/ccr-accounts', { params }),
+ Promise.resolve({ success: true, data: [] }) // droid 占位
+ )
+ break
+ case 'droid':
+ requests.push(
+ Promise.resolve({ success: true, data: [] }), // claude 占位
+ Promise.resolve({ success: true, data: [] }), // claude-console 占位
+ Promise.resolve({ success: true, data: [] }), // bedrock 占位
+ Promise.resolve({ success: true, data: [] }), // gemini 占位
+ Promise.resolve({ success: true, data: [] }), // openai 占位
+ Promise.resolve({ success: true, data: [] }), // azure 占位
+ Promise.resolve({ success: true, data: [] }), // openai-responses 占位
+ Promise.resolve({ success: true, data: [] }), // ccr 占位
+ apiClient.get('/admin/droid-accounts', { params })
)
break
default:
@@ -2136,6 +2183,8 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
+ Promise.resolve({ success: true, data: [] }),
+ Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] })
)
break
@@ -2156,7 +2205,8 @@ const loadAccounts = async (forceReload = false) => {
openaiData,
azureOpenaiData,
openaiResponsesData,
- ccrData
+ ccrData,
+ droidData
] = await Promise.all(requests)
const allAccounts = []
@@ -2250,6 +2300,15 @@ const loadAccounts = async (forceReload = false) => {
allAccounts.push(...ccrAccounts)
}
+ // Droid 账户
+ if (droidData && droidData.success) {
+ const droidAccounts = (droidData.data || []).map((acc) => {
+ // Droid 不支持 API Key 绑定,固定为 0
+ return { ...acc, platform: 'droid', boundApiKeysCount: 0 }
+ })
+ allAccounts.push(...droidAccounts)
+ }
+
// 根据分组筛选器过滤账户
let filteredAccounts = allAccounts
if (groupFilter.value !== 'all') {
@@ -2542,6 +2601,8 @@ const resolveAccountDeleteEndpoint = (account) => {
return `/admin/ccr-accounts/${account.id}`
case 'gemini':
return `/admin/gemini-accounts/${account.id}`
+ case 'droid':
+ return `/admin/droid-accounts/${account.id}`
default:
return null
}
@@ -2720,6 +2781,8 @@ const resetAccountStatus = async (account) => {
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
} else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
+ } else if (account.platform === 'droid') {
+ endpoint = `/admin/droid-accounts/${account.id}/reset-status`
} else {
showToast('不支持的账户类型', 'error')
account.isResetting = false
@@ -2766,6 +2829,8 @@ const toggleSchedulable = async (account) => {
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
+ } else if (account.platform === 'droid') {
+ endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable`
} else {
showToast('该账户类型暂不支持调度控制', 'warning')
return