Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
jft0m
2025-10-13 11:32:02 +08:00
committed by GitHub
21 changed files with 729 additions and 344 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Your GitHub username for GitHub Sponsors
patreon: # Replace with your Patreon username if you have one
open_collective: # Replace with your Open Collective username if you have one
ko_fi: # Replace with your Ko-fi username if you have one
tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with your Community Bridge project-name
liberapay: # Replace with your Liberapay username
issuehunt: # Replace with your IssueHunt username
otechie: # Replace with your Otechie username
custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian)

View File

@@ -396,13 +396,6 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
如果后台添加了 Droid 类型账号池,请将基础地址改为:
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/droid/claude" # 根据实际情况替换域名/IP
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
@@ -454,8 +447,6 @@ requires_openai_auth = true
env_key = "CRS_OAI_KEY"
```
如需通过 Droid 类型账号池访问 Codex CLI只需将 `base_url` 改为 `http://127.0.0.1:3000/droid/openai`(其余配置保持不变)。
在 `~/.codex/auth.json` 文件中配置API密钥为 null
```json
@@ -555,23 +546,7 @@ gpt-5 # Codex使用固定模型ID
- API地址填入`http://你的服务器:3000/openai`
- API Key填入后台创建的API密钥cr_开头
- **重要**Codex只支持Openai-Response标准
- 💡 如果希望在 Cherry Studio 中使用 Droid 类型账号,请改填 `http://你的服务器:3000/droid/openai`,并保持其他设置不变。
**4. Droid账号接入**
```
# Claude Code / Droid CLI 使用的 API 地址
http://你的服务器:3000/droid/claude
# Codex CLI 使用的 API 地址
http://你的服务器:3000/droid/openai
```
配置步骤:
- 供应商类型选择"Anthropic"或"Openai-Response"(根据模型类型)
- API地址填入`http://你的服务器:3000/droid/claude` 或 `http://你的服务器:3000/droid/openai`
- API Key填入后台创建的API密钥cr_开头
- 建议自定义模型名称以区分 Droid 账号池
**Cherry Studio 地址格式重要说明:**
@@ -587,10 +562,10 @@ http://你的服务器:3000/droid/openai
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/droid/claude/` - 使用Droid类型Claude账号池服务于 Claude Code / Droid CLI
- `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池服务于 Codex CLI
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池只建议api调用或Droid Cli中使用
- 支持所有标准API端点messages、models等
**重要说明:**

View File

@@ -7,6 +7,57 @@ const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const ClientValidator = require('../validators/clientValidator')
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
renewIntervalSeconds: 30,
cleanupGraceSeconds: 30
}
const resolveConcurrencyConfig = () => {
if (typeof redis._getConcurrencyConfig === 'function') {
return redis._getConcurrencyConfig()
}
const raw = {
...FALLBACK_CONCURRENCY_CONFIG,
...(config.concurrency || {})
}
const toNumber = (value, fallback) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
return parsed
}
const leaseSeconds = Math.max(
toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds),
30
)
let renewIntervalSeconds
if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') {
renewIntervalSeconds = 0
} else {
renewIntervalSeconds = Math.max(
toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds),
0
)
}
const cleanupGraceSeconds = Math.max(
toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds),
0
)
return {
leaseSeconds,
renewIntervalSeconds,
cleanupGraceSeconds
}
}
const TOKEN_COUNT_PATHS = new Set([
'/v1/messages/count_tokens',
'/api/v1/messages/count_tokens',
@@ -116,13 +167,10 @@ const authenticateApiKey = async (req, res, next) => {
// 检查并发限制
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
if (!skipKeyRestrictions && concurrencyLimit > 0) {
const concurrencyConfig = config.concurrency || {}
const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30)
const rawRenewInterval =
typeof concurrencyConfig.renewIntervalSeconds === 'number'
? concurrencyConfig.renewIntervalSeconds
: 60
let renewIntervalSeconds = rawRenewInterval
const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } =
resolveConcurrencyConfig()
const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30)
let renewIntervalSeconds = configRenewIntervalSeconds
if (renewIntervalSeconds > 0) {
const maxSafeRenew = Math.max(leaseSeconds - 5, 15)
renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew)
@@ -215,6 +263,29 @@ const authenticateApiKey = async (req, res, next) => {
decrementConcurrency()
})
req.once('aborted', () => {
logger.warn(
`⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})`
)
decrementConcurrency()
})
req.once('error', (error) => {
logger.error(
`❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`,
error
)
decrementConcurrency()
})
res.once('error', (error) => {
logger.error(
`❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`,
error
)
decrementConcurrency()
})
// res.on('finish') 处理正常完成的情况
res.once('finish', () => {
logger.api(

View File

@@ -1575,13 +1575,53 @@ class RedisClient {
// 获取并发配置
_getConcurrencyConfig() {
const defaults = {
leaseSeconds: 900,
leaseSeconds: 300,
renewIntervalSeconds: 30,
cleanupGraceSeconds: 30
}
return {
const configValues = {
...defaults,
...(config.concurrency || {})
}
const normalizeNumber = (value, fallback, options = {}) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
if (options.allowZero && parsed === 0) {
return 0
}
if (options.min !== undefined && parsed < options.min) {
return options.min
}
return parsed
}
return {
leaseSeconds: normalizeNumber(configValues.leaseSeconds, defaults.leaseSeconds, {
min: 30
}),
renewIntervalSeconds: normalizeNumber(
configValues.renewIntervalSeconds,
defaults.renewIntervalSeconds,
{
allowZero: true,
min: 0
}
),
cleanupGraceSeconds: normalizeNumber(
configValues.cleanupGraceSeconds,
defaults.cleanupGraceSeconds,
{
min: 0
}
)
}
}
// 增加并发计数(基于租约的有序集合)
@@ -1650,10 +1690,10 @@ class RedisClient {
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local exists = redis.call('ZSCORE', key, member)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
local exists = redis.call('ZSCORE', key, member)
if exists then
redis.call('ZADD', key, expireAt, member)
if ttl > 0 then

View File

@@ -32,6 +32,36 @@ const ProxyHelper = require('../utils/proxyHelper')
const router = express.Router()
function normalizeNullableDate(value) {
if (value === undefined || value === null) {
return null
}
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
return value
}
function formatSubscriptionExpiry(account) {
if (!account || typeof account !== 'object') {
return account
}
const rawSubscription = account.subscriptionExpiresAt
const rawToken = account.tokenExpiresAt !== undefined ? account.tokenExpiresAt : account.expiresAt
const subscriptionExpiresAt = normalizeNullableDate(rawSubscription)
const tokenExpiresAt = normalizeNullableDate(rawToken)
return {
...account,
subscriptionExpiresAt,
tokenExpiresAt,
expiresAt: subscriptionExpiresAt
}
}
// 👥 用户管理
// 获取所有用户列表用于API Key分配
@@ -2082,6 +2112,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatSubscriptionExpiry(account)
// 获取会话窗口使用统计(仅对有活跃窗口的账户)
let sessionWindowUsage = null
@@ -2124,7 +2155,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
}
return {
...account,
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
@@ -2140,8 +2171,9 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
// 如果获取统计失败,返回空统计
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatSubscriptionExpiry(account)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -2155,8 +2187,9 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
`⚠️ Failed to get group info for account ${account.id}:`,
groupError.message
)
const formattedAccount = formatSubscriptionExpiry(account)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -2170,7 +2203,8 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error)
return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message })
@@ -2267,7 +2301,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId,
expiresAt
expiresAt,
subscriptionExpiresAt
} = req.body
if (!name) {
@@ -2311,7 +2346,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null // 账户订阅到期时间
expiresAt: subscriptionExpiresAt ?? expiresAt ?? null // 账户订阅到期时间
})
// 如果是分组类型,将账户添加到分组
@@ -2326,7 +2361,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`)
return res.json({ success: true, data: newAccount })
const responseAccount = formatSubscriptionExpiry(newAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to create Claude account:', error)
return res
@@ -2400,8 +2436,12 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -2605,14 +2645,16 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
@@ -2630,7 +2672,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
@@ -2646,7 +2688,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
groupError.message
)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -2659,7 +2701,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('❌ Failed to get Claude Console accounts:', error)
return res
@@ -2730,7 +2773,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`🎮 Admin created Claude Console account: ${name}`)
return res.json({ success: true, data: newAccount })
const responseAccount = formatSubscriptionExpiry(newAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to create Claude Console account:', error)
return res
@@ -2797,8 +2841,12 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -3028,12 +3076,13 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => {
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id)
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
@@ -3051,7 +3100,7 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => {
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
@@ -3067,7 +3116,7 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => {
groupError.message
)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -3080,7 +3129,8 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('❌ Failed to get CCR accounts:', error)
return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message })
@@ -3149,7 +3199,8 @@ router.post('/ccr-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`🔧 Admin created CCR account: ${name}`)
return res.json({ success: true, data: newAccount })
const responseAccount = formatSubscriptionExpiry(newAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to create CCR account:', error)
return res.status(500).json({ error: 'Failed to create CCR account', message: error.message })
@@ -3214,8 +3265,12 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => {
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -3433,12 +3488,13 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => {
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
@@ -3454,7 +3510,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => {
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -3468,7 +3524,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => {
groupError.message
)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -3481,7 +3537,8 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('❌ Failed to get Bedrock accounts:', error)
return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message })
@@ -3543,7 +3600,8 @@ router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`☁️ Admin created Bedrock account: ${name}`)
return res.json({ success: true, data: result.data })
const responseAccount = formatSubscriptionExpiry(result.data)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to create Bedrock account:', error)
return res
@@ -3582,8 +3640,12 @@ router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) =
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -3906,14 +3968,13 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
// 为每个账户添加使用统计信息与Claude账户相同的逻辑
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
@@ -3930,9 +3991,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -3946,7 +4005,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
groupError.message
)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -3959,7 +4018,8 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('❌ Failed to get Gemini accounts:', error)
return res.status(500).json({ error: 'Failed to get accounts', message: error.message })
@@ -3999,7 +4059,8 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
return res.json({ success: true, data: newAccount })
const responseAccount = formatSubscriptionExpiry(newAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to create Gemini account:', error)
return res.status(500).json({ error: 'Failed to create account', message: error.message })
@@ -4059,15 +4120,20 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) =>
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Gemini account: ${accountId}`)
return res.json({ success: true, data: updatedAccount })
const responseAccount = formatSubscriptionExpiry(updatedAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to update Gemini account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
@@ -7215,8 +7281,9 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatSubscriptionExpiry(account)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
@@ -7227,8 +7294,9 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
} catch (error) {
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatSubscriptionExpiry(account)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
@@ -7242,9 +7310,11 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`)
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({
success: true,
data: accountsWithStats
data: formattedAccounts
})
} catch (error) {
logger.error('获取 OpenAI 账户列表失败:', error)
@@ -7270,7 +7340,8 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
rateLimitDuration,
priority,
needsImmediateRefresh, // 是否需要立即刷新
requireRefreshSuccess // 是否必须刷新成功才能创建
requireRefreshSuccess, // 是否必须刷新成功才能创建
subscriptionExpiresAt
} = req.body
if (!name) {
@@ -7292,7 +7363,8 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
accountInfo: accountInfo || {},
proxy: proxy || null,
isActive: true,
schedulable: true
schedulable: true,
subscriptionExpiresAt: subscriptionExpiresAt || null
}
// 如果需要立即刷新且必须成功OpenAI 手动模式)
@@ -7328,9 +7400,11 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
const responseAccount = formatSubscriptionExpiry(refreshedAccount)
return res.json({
success: true,
data: refreshedAccount,
data: responseAccount,
message: '账户创建成功,并已获取完整 token 信息'
})
} catch (refreshError) {
@@ -7392,9 +7466,11 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
const responseAccount = formatSubscriptionExpiry(createdAccount)
return res.json({
success: true,
data: createdAccount
data: responseAccount
})
} catch (error) {
logger.error('创建 OpenAI 账户失败:', error)
@@ -7571,10 +7647,19 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
: currentAccount.emailVerified
}
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt (订阅过期时间)
// 注意:这里不影响上面 OAuth token 的 expiresAt 字段
if ('expiresAt' in updates && !updates.openaiOauth?.expires_in) {
const hasOauthExpiry = Boolean(updates.openaiOauth?.expires_in)
// 处理订阅过期时间字段:优先使用 subscriptionExpiresAt兼容旧版的 expiresAt
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updateData.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt') && !hasOauthExpiry) {
updateData.subscriptionExpiresAt = updates.expiresAt
}
if (
!hasOauthExpiry &&
Object.prototype.hasOwnProperty.call(updateData, 'subscriptionExpiresAt')
) {
delete updateData.expiresAt
}
@@ -7592,7 +7677,8 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
}
logger.success(`📝 Admin updated OpenAI account: ${id}`)
return res.json({ success: true, data: updatedAccount })
const responseAccount = formatSubscriptionExpiry(updatedAccount)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('❌ Failed to update OpenAI account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
@@ -7673,9 +7759,11 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) =>
`${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
)
const responseAccount = formatSubscriptionExpiry(account)
return res.json({
success: true,
data: account
data: responseAccount
})
} catch (error) {
logger.error('切换 OpenAI 账户状态失败:', error)
@@ -7781,11 +7869,12 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
// 为每个账户添加使用统计信息和分组信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
@@ -7798,7 +7887,7 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
...formattedAccount,
groupInfos,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
@@ -7809,7 +7898,7 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
} catch (groupError) {
logger.debug(`Failed to get group info for account ${account.id}:`, groupError)
return {
...account,
...formattedAccount,
groupInfos: [],
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
@@ -7822,9 +7911,11 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
})
)
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
res.json({
success: true,
data: accountsWithStats
data: formattedAccounts
})
} catch (error) {
logger.error('Failed to fetch Azure OpenAI accounts:', error)
@@ -7943,9 +8034,11 @@ router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
}
}
const responseAccount = formatSubscriptionExpiry(account)
res.json({
success: true,
data: account,
data: responseAccount,
message: 'Azure OpenAI account created successfully'
})
} catch (error) {
@@ -7966,16 +8059,21 @@ router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) =>
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates)
const responseAccount = formatSubscriptionExpiry(account)
res.json({
success: true,
data: account,
data: responseAccount,
message: 'Azure OpenAI account updated successfully'
})
} catch (error) {
@@ -8228,6 +8326,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
// 处理额度信息、使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
// 检查是否需要重置额度
const today = redis.getDateStringInTimezone()
@@ -8282,7 +8381,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
}
return {
...account,
...formattedAccount,
boundApiKeysCount: boundCount,
usage: {
daily: usageStats.daily,
@@ -8293,7 +8392,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
} catch (error) {
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
return {
...account,
...formattedAccount,
boundApiKeysCount: 0,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
@@ -8305,7 +8404,9 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
})
)
res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('Failed to get OpenAI-Responses accounts:', error)
res.status(500).json({ success: false, message: error.message })
@@ -8316,7 +8417,8 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
try {
const account = await openaiResponsesAccountService.createAccount(req.body)
res.json({ success: true, account })
const responseAccount = formatSubscriptionExpiry(account)
res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('Failed to create OpenAI-Responses account:', error)
res.status(500).json({
@@ -8346,8 +8448,12 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res)
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -8357,7 +8463,13 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res)
return res.status(400).json(result)
}
res.json({ success: true, ...result })
const updatedAccountData = await openaiResponsesAccountService.getAccount(id)
if (updatedAccountData) {
updatedAccountData.apiKey = '***'
}
const responseAccount = formatSubscriptionExpiry(updatedAccountData)
res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('Failed to update OpenAI-Responses account:', error)
res.status(500).json({
@@ -8687,6 +8799,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
// 添加使用统计
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
const formattedAccount = formatSubscriptionExpiry(account)
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
let groupInfos = []
@@ -8716,10 +8829,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
}, 0)
return {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
// OAuth token 的原始 expiresAt 保留在内部使用
expiresAt: account.subscriptionExpiresAt || null,
...formattedAccount,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
@@ -8732,9 +8842,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
} catch (error) {
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
return {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
...formattedAccount,
boundApiKeysCount: 0,
groupInfos: [],
usage: {
@@ -8747,7 +8855,9 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
})
)
return res.json({ success: true, data: accountsWithStats })
const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry)
return res.json({ success: true, data: formattedAccounts })
} catch (error) {
logger.error('Failed to get Droid accounts:', error)
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
@@ -8804,7 +8914,8 @@ router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
}
logger.success(`Created Droid account: ${account.name} (${account.id})`)
return res.json({ success: true, data: account })
const responseAccount = formatSubscriptionExpiry(account)
return res.json({ success: true, data: responseAccount })
} catch (error) {
logger.error('Failed to create Droid account:', error)
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
@@ -8851,8 +8962,12 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
// 映射字段名前端的expiresAt -> 后端的subscriptionExpiresAt
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) {
delete mappedUpdates.expiresAt
}
@@ -8888,7 +9003,8 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
}
}
return res.json({ success: true, data: account })
const responseAccount = formatSubscriptionExpiry(account)
return res.json({ success: true, data: responseAccount })
} 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 })

View File

@@ -65,6 +65,19 @@ const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
// 加密函数
function encrypt(text) {
if (!text) {
@@ -133,6 +146,7 @@ async function createAccount(accountData) {
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false',
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(accountData.subscriptionExpiresAt || ''),
createdAt: now,
updatedAt: now
}
@@ -152,7 +166,10 @@ async function createAccount(accountData) {
}
logger.info(`Created Azure OpenAI account: ${accountId}`)
return account
return {
...account,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}
}
// 获取账户
@@ -187,6 +204,11 @@ async function getAccount(accountId) {
}
}
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData
}
@@ -218,6 +240,13 @@ async function updateAccount(accountId, updates) {
: JSON.stringify(updates.supportedModels)
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
delete updates.expiresAt
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -244,6 +273,10 @@ async function updateAccount(accountId, updates) {
}
}
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount
}
@@ -303,7 +336,8 @@ async function getAllAccounts() {
accounts.push({
...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false'
schedulable: accountData.schedulable !== 'false',
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
})
}
}

View File

@@ -6,6 +6,19 @@ const config = require('../../config/config')
const bedrockRelayService = require('./bedrockRelayService')
const LRUCache = require('../utils/lruCache')
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
class BedrockAccountService {
constructor() {
// 加密相关常量
@@ -40,7 +53,8 @@ class BedrockAccountService {
accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
credentialType = 'default', // 'default', 'access_key', 'bearer_token'
subscriptionExpiresAt = null
} = options
const accountId = uuidv4()
@@ -56,6 +70,7 @@ class BedrockAccountService {
priority,
schedulable,
credentialType,
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: 'bedrock' // 标识这是Bedrock账户
@@ -84,6 +99,7 @@ class BedrockAccountService {
priority,
schedulable,
credentialType,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
createdAt: accountData.createdAt,
type: 'bedrock'
}
@@ -106,6 +122,11 @@ class BedrockAccountService {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
}
account.subscriptionExpiresAt =
account.subscriptionExpiresAt && account.subscriptionExpiresAt !== ''
? account.subscriptionExpiresAt
: null
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
return {
@@ -145,7 +166,9 @@ class BedrockAccountService {
createdAt: account.createdAt,
updatedAt: account.updatedAt,
type: 'bedrock',
hasCredentials: !!account.awsCredentials
hasCredentials: !!account.awsCredentials,
expiresAt: account.expiresAt || null,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
})
}
}
@@ -211,6 +234,14 @@ class BedrockAccountService {
account.credentialType = updates.credentialType
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
updates.subscriptionExpiresAt
)
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
}
// 更新AWS凭证
if (updates.awsCredentials !== undefined) {
if (updates.awsCredentials) {
@@ -245,7 +276,9 @@ class BedrockAccountService {
schedulable: account.schedulable,
credentialType: account.credentialType,
updatedAt: account.updatedAt,
type: 'bedrock'
type: 'bedrock',
expiresAt: account.expiresAt || null,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}
}
} catch (error) {

View File

@@ -6,6 +6,19 @@ const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
class CcrAccountService {
constructor() {
// 加密相关常量
@@ -49,7 +62,8 @@ class CcrAccountService {
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
subscriptionExpiresAt = null
} = options
// 验证必填字段
@@ -91,7 +105,8 @@ class CcrAccountService {
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间
quotaStoppedAt: '', // 因额度停用的时间
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
}
const client = redis.getClientSafe()
@@ -127,7 +142,8 @@ class CcrAccountService {
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null
quotaStoppedAt: null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
}
}
@@ -170,7 +186,9 @@ class CcrAccountService {
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null
quotaStoppedAt: accountData.quotaStoppedAt || null,
expiresAt: accountData.expiresAt || null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
})
}
}
@@ -225,6 +243,11 @@ class CcrAccountService {
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
)
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData
}
@@ -288,6 +311,14 @@ class CcrAccountService {
updatedData.quotaResetTime = updates.quotaResetTime
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
updates.subscriptionExpiresAt
)
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
// 处理共享账户集合变更

View File

@@ -185,6 +185,10 @@ class ClaudeAccountService {
status: accountData.status,
createdAt: accountData.createdAt,
expiresAt: accountData.expiresAt,
subscriptionExpiresAt:
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null,
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
autoStopOnWarning,
useUnifiedUserAgent,
@@ -491,7 +495,11 @@ class ClaudeAccountService {
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
expiresAt: account.subscriptionExpiresAt || null, // 账户订阅到期时间
expiresAt: account.expiresAt || null,
subscriptionExpiresAt:
account.subscriptionExpiresAt && account.subscriptionExpiresAt !== ''
? account.subscriptionExpiresAt
: null,
// 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况,避免返回 ['']
scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [],

View File

@@ -6,6 +6,19 @@ const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
class ClaudeConsoleAccountService {
constructor() {
// 加密相关常量
@@ -52,7 +65,8 @@ class ClaudeConsoleAccountService {
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
subscriptionExpiresAt = null
} = options
// 验证必填字段
@@ -94,7 +108,8 @@ class ClaudeConsoleAccountService {
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间
quotaStoppedAt: '', // 因额度停用的时间
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
}
const client = redis.getClientSafe()
@@ -130,7 +145,8 @@ class ClaudeConsoleAccountService {
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null
quotaStoppedAt: null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
}
}
@@ -173,7 +189,9 @@ class ClaudeConsoleAccountService {
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null
quotaStoppedAt: accountData.quotaStoppedAt || null,
expiresAt: accountData.expiresAt || null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
})
}
}
@@ -224,6 +242,11 @@ class ClaudeConsoleAccountService {
accountData.proxy = JSON.parse(accountData.proxy)
}
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
logger.debug(
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
)
@@ -318,6 +341,14 @@ class ClaudeConsoleAccountService {
updatedData.quotaStoppedAt = updates.quotaStoppedAt
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
updates.subscriptionExpiresAt
)
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
}
// 处理账户类型变更
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
updatedData.accountType = updates.accountType

View File

@@ -487,9 +487,12 @@ class ClaudeConsoleRelayService {
// 解析SSE数据寻找usage信息
for (const line of lines) {
if (line.startsWith('data: ') && line.length > 6) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const jsonStr = line.slice(6)
const data = JSON.parse(jsonStr)
// 收集usage数据

View File

@@ -1487,9 +1487,12 @@ class ClaudeRelayService {
for (const line of lines) {
// 解析SSE数据寻找usage信息
if (line.startsWith('data: ') && line.length > 6) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const jsonStr = line.slice(6)
const data = JSON.parse(jsonStr)
// 收集来自不同事件的usage数据

View File

@@ -42,6 +42,19 @@ function generateEncryptionKey() {
return _encryptionKeyCache
}
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
// Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
@@ -333,6 +346,10 @@ async function createAccount(accountData) {
let refreshToken = ''
let expiresAt = ''
const subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
accountData.subscriptionExpiresAt || ''
)
if (accountData.geminiOauth || accountData.accessToken) {
// 如果提供了完整的 OAuth 数据
if (accountData.geminiOauth) {
@@ -404,7 +421,8 @@ async function createAccount(accountData) {
createdAt: now,
updatedAt: now,
lastUsedAt: '',
lastRefreshAt: ''
lastRefreshAt: '',
subscriptionExpiresAt
}
// 保存到 Redis
@@ -428,6 +446,10 @@ async function createAccount(accountData) {
}
}
if (!returnAccount.subscriptionExpiresAt) {
returnAccount.subscriptionExpiresAt = null
}
return returnAccount
}
@@ -464,6 +486,10 @@ async function getAccount(accountId) {
// 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致)
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true只有明确设置为'false'才为false
if (!accountData.subscriptionExpiresAt) {
accountData.subscriptionExpiresAt = null
}
return accountData
}
@@ -477,6 +503,10 @@ async function updateAccount(accountId, updates) {
const now = new Date().toISOString()
updates.updatedAt = now
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
}
// 检查是否新增了 refresh token
// existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回)
const oldRefreshToken = existingAccount.refreshToken || ''
@@ -586,6 +616,10 @@ async function updateAccount(accountId, updates) {
}
}
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount
}
@@ -649,6 +683,7 @@ async function getAllAccounts() {
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 添加 scopes 字段用于判断认证方式
// 处理空字符串和默认值的情况
scopes:

View File

@@ -194,6 +194,19 @@ function buildCodexUsageSnapshot(accountData) {
}
}
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken, proxy = null) {
try {
@@ -517,6 +530,13 @@ async function createAccount(accountData) {
// 处理账户信息
const accountInfo = accountData.accountInfo || {}
const tokenExpiresAt = oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
: ''
const subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
accountData.subscriptionExpiresAt || accountInfo.subscriptionExpiresAt || ''
)
// 检查邮箱是否已经是加密格式包含冒号分隔的32位十六进制字符
const isEmailEncrypted =
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
@@ -553,9 +573,8 @@ async function createAccount(accountData) {
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
// 过期时间
expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 默认1年
expiresAt: tokenExpiresAt,
subscriptionExpiresAt,
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
@@ -580,7 +599,10 @@ async function createAccount(accountData) {
}
logger.info(`Created OpenAI account: ${accountId}`)
return account
return {
...account,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}
}
// 获取账户
@@ -623,6 +645,11 @@ async function getAccount(accountId) {
}
}
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData
}
@@ -656,6 +683,10 @@ async function updateAccount(accountId, updates) {
updates.email = encrypt(updates.email)
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
}
// 处理代理配置
if (updates.proxy) {
updates.proxy =
@@ -688,6 +719,10 @@ async function updateAccount(accountId, updates) {
}
}
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount
}
@@ -770,6 +805,8 @@ async function getAllAccounts() {
}
}
const subscriptionExpiresAt = accountData.subscriptionExpiresAt || null
// 不解密敏感字段,只返回基本信息
accounts.push({
...accountData,
@@ -784,6 +821,7 @@ async function getAllAccounts() {
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记
hasRefreshToken: hasRefreshTokenFlag,
subscriptionExpiresAt,
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {

View File

@@ -5,6 +5,19 @@ const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
function normalizeSubscriptionExpiresAt(value) {
if (value === undefined || value === null || value === '') {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toISOString()
}
class OpenAIResponsesAccountService {
constructor() {
// 加密相关常量
@@ -49,7 +62,8 @@ class OpenAIResponsesAccountService {
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
rateLimitDuration = 60 // 限流时间(分钟)
rateLimitDuration = 60, // 限流时间(分钟)
subscriptionExpiresAt = null
} = options
// 验证必填字段
@@ -88,7 +102,8 @@ class OpenAIResponsesAccountService {
dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(),
quotaResetTime,
quotaStoppedAt: ''
quotaStoppedAt: '',
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
}
// 保存到 Redis
@@ -98,6 +113,7 @@ class OpenAIResponsesAccountService {
return {
...accountData,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
apiKey: '***' // 返回时隐藏敏感信息
}
}
@@ -124,6 +140,11 @@ class OpenAIResponsesAccountService {
}
}
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData
}
@@ -151,6 +172,13 @@ class OpenAIResponsesAccountService {
: updates.baseApi
}
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
delete updates.expiresAt
}
// 更新 Redis
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
@@ -257,6 +285,10 @@ class OpenAIResponsesAccountService {
accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true'
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
accounts.push(accountData)
}

View File

@@ -261,6 +261,10 @@
background: rgba(0, 0, 0, 0.6);
}
.dark .modal {
background: rgba(0, 0, 0, 0.75);
}
.modal-content {
background: rgba(255, 255, 255, 0.98);
border-radius: 24px;
@@ -271,6 +275,14 @@
/* 移除模糊效果 */
}
.dark .modal-content {
background: rgba(17, 24, 39, 0.95);
border: 1px solid rgba(75, 85, 99, 0.3);
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* 弹窗滚动内容样式 */
.modal-scroll-content {
max-height: calc(90vh - 160px);

View File

@@ -2,7 +2,7 @@
<Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white/90 p-4 shadow-xl backdrop-blur-xl dark:bg-gray-800/95 dark:shadow-2xl sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
@@ -16,7 +16,7 @@
</h3>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
@@ -419,18 +419,6 @@ watch(
</script>
<style scoped>
.modal-content {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: rgba(17, 24, 39, 0.85);
}
.loading-spinner {
width: 20px;
height: 20px;

View File

@@ -346,151 +346,76 @@
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Claude 专属账号</label
>
<select
v-model="form.claudeAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions && !['all', 'claude'].includes(form.permissions)"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.claudeGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
<option
v-for="account in localAccounts.claude"
:key="account.id"
:value="
account.platform === 'claude-console' ? `console:${account.id}` : account.id
"
>
{{ account.name }} ({{
account.platform === 'claude-console' ? 'Console' : 'OAuth'
}})
</option>
</optgroup>
</select>
<AccountSelector
v-model="claudeAccountSelectorValue"
:accounts="localAccounts.claude"
default-option-text="请选择Claude账号"
:disabled="!isServiceSelectable('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
:special-options="accountSpecialOptions"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Gemini 专属账号</label
>
<select
v-model="form.geminiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions && !['all', 'gemini'].includes(form.permissions)"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.geminiGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
<option
v-for="account in localAccounts.gemini"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
<AccountSelector
v-model="geminiAccountSelectorValue"
:accounts="localAccounts.gemini"
default-option-text="请选择Gemini账号"
:disabled="!isServiceSelectable('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
:special-options="accountSpecialOptions"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>OpenAI 专属账号</label
>
<select
v-model="form.openaiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions && !['all', 'openai'].includes(form.permissions)"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.openaiGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
<option
v-for="account in localAccounts.openai"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
<AccountSelector
v-model="openaiAccountSelectorValue"
:accounts="localAccounts.openai"
default-option-text="请选择OpenAI账号"
:disabled="!isServiceSelectable('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
:special-options="accountSpecialOptions"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Bedrock 专属账号</label
>
<select
v-model="form.bedrockAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions && !['all', 'openai'].includes(form.permissions)"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
<option
v-for="account in localAccounts.bedrock"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
<AccountSelector
v-model="bedrockAccountSelectorValue"
:accounts="localAccounts.bedrock"
default-option-text="请选择Bedrock账号"
:disabled="!isServiceSelectable('openai')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
:special-options="accountSpecialOptions"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Droid 专属账号</label
>
<select
v-model="form.droidAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions && !['all', 'droid'].includes(form.permissions)"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.droidGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.droidGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.droid.length > 0" label="专属账号">
<option
v-for="account in localAccounts.droid"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
<AccountSelector
v-model="droidAccountSelectorValue"
:accounts="localAccounts.droid"
default-option-text="请选择Droid账号"
:disabled="!isServiceSelectable('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
:special-options="accountSpecialOptions"
/>
</div>
</div>
</div>
@@ -524,6 +449,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
import AccountSelector from '@/components/common/AccountSelector.vue'
const props = defineProps({
selectedKeys: {
@@ -594,6 +520,37 @@ const form = reactive({
isActive: null // null表示不修改
})
const UNCHANGED_OPTION_VALUE = '__KEEP_ORIGINAL__'
const accountSpecialOptions = [
{ value: UNCHANGED_OPTION_VALUE, label: '不修改' },
{ value: 'SHARED_POOL', label: '使用共享账号池' }
]
const createAccountSelectorModel = (field) =>
computed({
get: () => (form[field] === '' ? UNCHANGED_OPTION_VALUE : form[field]),
set: (value) => {
if (!value || value === UNCHANGED_OPTION_VALUE) {
form[field] = ''
} else {
form[field] = value
}
}
})
const claudeAccountSelectorValue = createAccountSelectorModel('claudeAccountId')
const geminiAccountSelectorValue = createAccountSelectorModel('geminiAccountId')
const openaiAccountSelectorValue = createAccountSelectorModel('openaiAccountId')
const bedrockAccountSelectorValue = createAccountSelectorModel('bedrockAccountId')
const droidAccountSelectorValue = createAccountSelectorModel('droidAccountId')
const isServiceSelectable = (service) => {
if (!form.permissions) return true
if (form.permissions === 'all') return true
return form.permissions === service
}
// 标签管理方法
const addTag = () => {
if (newTag.value && newTag.value.trim()) {

View File

@@ -200,10 +200,6 @@ const getDisplayedApiKey = () => {
}
}
const droidEndpoint = computed(() => {
return getBaseUrlPrefix() + '/droid/claude'
})
// 通用复制工具,包含降级处理
const copyTextWithFallback = async (text, successMessage) => {
try {
@@ -235,9 +231,7 @@ const copyFullConfig = async () => {
// 构建环境变量配置格式
const configText = `ANTHROPIC_BASE_URL="${currentBaseUrl.value}"
ANTHROPIC_AUTH_TOKEN="${key}"
# 提示:如需调用 /droid/claude 端点(已在后台添加 Droid 账号),请将 ANTHROPIC_BASE_URL 改为 "${droidEndpoint.value}" 或根据实际环境调整。`
ANTHROPIC_AUTH_TOKEN="${key}"`
await copyTextWithFallback(configText, '配置信息已复制到剪贴板')
}

View File

@@ -62,6 +62,28 @@
<!-- 选项列表 -->
<div class="custom-scrollbar flex-1 overflow-y-auto">
<!-- 特殊选项 -->
<div
v-if="specialOptionsList.length > 0"
class="border-b border-gray-200 dark:border-gray-600"
>
<div
v-for="option in specialOptionsList"
:key="`special-${option.value}`"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === option.value }"
@click="selectAccount(option.value)"
>
<span class="text-gray-700 dark:text-gray-300">{{ option.label }}</span>
<span
v-if="option.description"
class="ml-2 text-xs text-gray-400 dark:text-gray-500"
>
{{ option.description }}
</span>
</div>
</div>
<!-- 默认选项 -->
<div
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -264,6 +286,10 @@ const props = defineProps({
defaultOptionText: {
type: String,
default: '使用共享账号池'
},
specialOptions: {
type: Array,
default: () => []
}
})
@@ -276,9 +302,17 @@ const dropdownRef = ref(null)
const dropdownStyle = ref({})
const triggerRef = ref(null)
const lastDirection = ref('') // 记住上次的显示方向
const specialOptionsList = computed(() => props.specialOptions || [])
// 获取选中的标签
const selectedLabel = computed(() => {
const matchedSpecial = specialOptionsList.value.find(
(option) => option.value === props.modelValue
)
if (matchedSpecial) {
return matchedSpecial.label
}
// 如果没有选中值,显示默认选项文本
if (!props.modelValue) return props.defaultOptionText

View File

@@ -296,15 +296,6 @@
</p>
</div>
</div>
<p class="mt-3 text-xs text-purple-700 dark:text-purple-300 sm:text-sm">
🚀 如果你在后台添加了 <strong>Droid</strong> 类型账号请将上述命令中的
<code class="rounded bg-purple-100 px-1 dark:bg-purple-900">{{ currentBaseUrl }}</code>
替换为
<code class="rounded bg-purple-100 px-1 dark:bg-purple-900">{{
droidClaudeBaseUrl
}}</code
>其余配置保持不变
</p>
</div>
<!-- VSCode 插件配置 -->
@@ -514,17 +505,6 @@
{{ line }}
</div>
</div>
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-300 sm:text-sm">
🚀 如果你要使用 <strong>Droid</strong> 类型账号池请把配置中的
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
openaiBaseUrl
}}</code>
替换为
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
droidOpenaiBaseUrl
}}</code
>
</p>
<p class="mt-3 text-sm text-yellow-700 dark:text-yellow-300">
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900"
@@ -1009,15 +989,6 @@
</div>
</div>
</div>
<p class="mt-3 text-xs text-orange-700 dark:text-orange-300 sm:text-sm">
🚀 如果你创建了 <strong>Droid</strong> 类型账号请把上述命令中的
<code class="rounded bg-orange-100 px-1 dark:bg-orange-900">{{ currentBaseUrl }}</code>
替换为
<code class="rounded bg-orange-100 px-1 dark:bg-orange-900">{{
droidClaudeBaseUrl
}}</code
>其余配置保持不变
</p>
</div>
<!-- VSCode 插件配置 (macOS) -->
@@ -1185,17 +1156,6 @@
{{ line }}
</div>
</div>
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-300 sm:text-sm">
🚀 如果你要使用 <strong>Droid</strong> 类型账号池请把配置中的
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
openaiBaseUrl
}}</code>
替换为
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
droidOpenaiBaseUrl
}}</code
>
</p>
<p class="mt-3 text-sm text-yellow-700 dark:text-yellow-300">
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900"
@@ -1674,15 +1634,6 @@
</div>
</div>
</div>
<p class="mt-3 text-xs text-orange-700 dark:text-orange-300 sm:text-sm">
🚀 如果你创建了 <strong>Droid</strong> 类型账号请把上述命令中的
<code class="rounded bg-orange-100 px-1 dark:bg-orange-900">{{ currentBaseUrl }}</code>
替换为
<code class="rounded bg-orange-100 px-1 dark:bg-orange-900">{{
droidClaudeBaseUrl
}}</code
>其余配置保持不变
</p>
</div>
<!-- Gemini CLI 环境变量设置 -->
@@ -1818,17 +1769,6 @@
{{ line }}
</div>
</div>
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-300 sm:text-sm">
🚀 如果你要使用 <strong>Droid</strong> 类型账号池请把配置中的
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
openaiBaseUrl
}}</code>
替换为
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900">{{
droidOpenaiBaseUrl
}}</code
>
</p>
<p class="mt-3 text-sm text-yellow-700 dark:text-yellow-300">
<code class="rounded bg-yellow-100 px-1 dark:bg-yellow-900"
@@ -2293,8 +2233,6 @@ const codexConfigContent = computed(() => {
'[model_providers.crs]',
'name = "crs"',
`base_url = "${openaiBaseUrl.value}"`,
'# 若使用 Droid 类型账号,请改为以下地址',
`# base_url = "${droidOpenaiBaseUrl.value}"`,
'wire_api = "responses"',
'requires_openai_auth = true',
'env_key = "CRS_OAI_KEY"'