feat(admin): 添加账户导出同步 API

- 新增 /api/accounts 端点,支持导出所有账户数据
- 新增 /api/proxies 端点,支持导出所有代理配置
- 支持 Sub2API 从 CRS 批量同步账户
- 包含完整的 credentials 和 extra 字段
- 提供账户类型标识 (oauth/setup_token/api_key)

相关 PR: Sub2API 端实现账户同步功能
This commit is contained in:
IanShaw027
2025-12-24 17:35:11 -08:00
parent 59ce0f091c
commit b2dfc2eb25
2 changed files with 450 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ const usageStatsRoutes = require('./usageStats')
const systemRoutes = require('./system') const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency') const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig') const claudeRelayConfigRoutes = require('./claudeRelayConfig')
const syncRoutes = require('./sync')
// 挂载所有子路由 // 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径) // 使用完整路径的模块(直接挂载到根路径)
@@ -39,6 +40,7 @@ router.use('/', usageStatsRoutes)
router.use('/', systemRoutes) router.use('/', systemRoutes)
router.use('/', concurrencyRoutes) router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes) router.use('/', claudeRelayConfigRoutes)
router.use('/', syncRoutes)
// 使用相对路径的模块(需要指定基础路径前缀) // 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes) router.use('/account-groups', accountGroupsRoutes)

448
src/routes/admin/sync.js Normal file
View File

@@ -0,0 +1,448 @@
/**
* Admin Routes - Sync / Export (for migration)
* Exports account data (including secrets) for safe server-to-server syncing.
*/
const express = require('express')
const router = express.Router()
const { authenticateAdmin } = require('../../middleware/auth')
const redis = require('../../models/redis')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const logger = require('../../utils/logger')
function toBool(value, defaultValue = false) {
if (value === undefined || value === null || value === '') {
return defaultValue
}
if (value === true || value === 'true') {
return true
}
if (value === false || value === 'false') {
return false
}
return defaultValue
}
function normalizeProxy(proxy) {
if (!proxy || typeof proxy !== 'object') {
return null
}
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
const host = proxy.host || ''
const port = Number(proxy.port || 0)
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
return null
}
return {
protocol: String(protocol),
host: String(host),
port,
username: proxy.username ? String(proxy.username) : '',
password: proxy.password ? String(proxy.password) : ''
}
}
function buildModelMappingFromSupportedModels(supportedModels) {
if (!supportedModels) {
return null
}
if (Array.isArray(supportedModels)) {
const mapping = {}
for (const model of supportedModels) {
if (typeof model === 'string' && model.trim()) {
mapping[model.trim()] = model.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
if (typeof supportedModels === 'object') {
const mapping = {}
for (const [from, to] of Object.entries(supportedModels)) {
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
mapping[from.trim()] = to.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
return null
}
function safeParseJson(raw, fallback = null) {
if (!raw || typeof raw !== 'string') {
return fallback
}
try {
return JSON.parse(raw)
} catch (_) {
return fallback
}
}
// Export accounts for migration (includes secrets).
// GET /admin/sync/export-accounts?include_secrets=true
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
try {
const includeSecrets = toBool(req.query.include_secrets, false)
if (!includeSecrets) {
return res.status(400).json({
success: false,
error: 'include_secrets_required',
message: 'Set include_secrets=true to export secrets'
})
}
// ===== Claude official OAuth / Setup Token accounts =====
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
const claudeAccounts = rawClaudeAccounts.map((account) => {
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
let decryptedClaudeAiOauth = null
if (account.claudeAiOauth) {
try {
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
} catch (_) {
decryptedClaudeAiOauth = null
}
}
const rawScopes =
account.scopes && account.scopes.trim()
? account.scopes
: decryptedClaudeAiOauth?.scopes
? decryptedClaudeAiOauth.scopes.join(' ')
: ''
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
const authType = isOAuth ? 'oauth' : 'setup-token'
const accessToken =
account.accessToken && String(account.accessToken).trim()
? claudeAccountService._decryptSensitiveData(account.accessToken)
: decryptedClaudeAiOauth?.accessToken || ''
const refreshToken =
account.refreshToken && String(account.refreshToken).trim()
? claudeAccountService._decryptSensitiveData(account.refreshToken)
: decryptedClaudeAiOauth?.refreshToken || ''
let expiresAt = null
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
expiresAt = new Date(expiresAtMs).toISOString()
} else if (decryptedClaudeAiOauth?.expiresAt) {
try {
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
} catch (_) {
expiresAt = null
}
}
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
let orgUuid = null
let accountUuid = null
if (account.subscriptionInfo) {
try {
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
orgUuid = subscriptionInfo.organizationUuid || null
accountUuid = subscriptionInfo.accountUuid || null
} catch (_) {
// Ignore parse errors
}
}
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (expiresAt) {
try {
const expiresAtTime = new Date(expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
if (!expiresIn && isOAuth) {
expiresIn = 28800 // 8 hours
}
const credentials = {
access_token: accessToken,
refresh_token: refreshToken || undefined,
expires_at: expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (orgUuid) credentials.org_uuid = orgUuid
if (accountUuid) credentials.account_uuid = accountUuid
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'claude-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'claude',
crs_auth_type: authType,
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_subscription_info: account.subscriptionInfo || undefined
}
return {
kind: 'claude-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'claude',
authType,
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
}
})
// ===== Claude Console API Key accounts =====
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
const claudeConsoleAccounts = []
for (const summary of claudeConsoleSummaries) {
const full = await claudeConsoleAccountService.getAccount(summary.id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
const credentials = {
api_key: full.apiKey,
base_url: full.apiUrl
}
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
claudeConsoleAccounts.push({
kind: 'claude-console-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'claude-console',
isActive: full.isActive === true,
schedulable: full.schedulable !== false,
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'claude-console-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'claude-console',
crs_is_active: full.isActive === true,
crs_schedulable: full.schedulable !== false,
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
// ===== OpenAI OAuth accounts =====
const openaiOAuthAccounts = []
{
const client = redis.getClientSafe()
const openaiKeys = await client.keys('openai:account:*')
for (const key of openaiKeys) {
const id = key.split(':').slice(2).join(':')
const account = await openaiAccountService.getAccount(id)
if (!account) {
continue
}
const accessToken = account.accessToken ? openaiAccountService.decrypt(account.accessToken) : ''
if (!accessToken) {
// Skip broken/legacy records without decryptable token
continue
}
const scopes =
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
? account.scopes.trim().split(' ')
: []
const proxy = normalizeProxy(account.proxy)
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (account.expiresAt) {
try {
const expiresAtTime = new Date(account.expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
if (!expiresIn) {
expiresIn = 864000 // 10 days
}
const credentials = {
access_token: accessToken,
refresh_token: account.refreshToken || undefined,
id_token: account.idToken || undefined,
expires_at: account.expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (account.accountId) credentials.chatgpt_account_id = account.accountId
if (account.chatgptUserId) credentials.chatgpt_user_id = account.chatgptUserId
if (account.organizationId) credentials.organization_id = account.organizationId
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'openai-oauth-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'openai',
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_email: account.email || undefined,
crs_chatgpt_account_id: account.accountId || undefined,
crs_chatgpt_user_id: account.chatgptUserId || undefined,
crs_organization_id: account.organizationId || undefined
}
openaiOAuthAccounts.push({
kind: 'openai-oauth-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'openai',
authType: 'oauth',
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
})
}
}
// ===== OpenAI Responses API Key accounts =====
const openaiResponsesAccounts = []
const client = redis.getClientSafe()
const openaiResponseKeys = await client.keys('openai_responses_account:*')
for (const key of openaiResponseKeys) {
const id = key.split(':').slice(1).join(':')
const full = await openaiResponsesAccountService.getAccount(id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const credentials = {
api_key: full.apiKey,
base_url: full.baseApi
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
openaiResponsesAccounts.push({
kind: 'openai-responses-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'openai-responses',
isActive: full.isActive === 'true',
schedulable: full.schedulable !== 'false',
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'openai-responses-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'openai-responses',
crs_is_active: full.isActive === 'true',
crs_schedulable: full.schedulable !== 'false',
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
return res.json({
success: true,
data: {
exportedAt: new Date().toISOString(),
claudeAccounts,
claudeConsoleAccounts,
openaiOAuthAccounts,
openaiResponsesAccounts
}
})
} catch (error) {
logger.error('❌ Failed to export accounts for sync:', error)
return res.status(500).json({
success: false,
error: 'export_failed',
message: error.message
})
}
})
module.exports = router