Merge branch 'feature/account-subscription-expiry-check' into dev

This commit is contained in:
shaw
2025-10-14 17:47:37 +08:00
14 changed files with 667 additions and 593 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -65,19 +65,6 @@ const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts' const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:' 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) { function encrypt(text) {
if (!text) { if (!text) {
@@ -142,11 +129,15 @@ async function createAccount(accountData) {
supportedModels: JSON.stringify( supportedModels: JSON.stringify(
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k'] accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
), ),
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Azure OpenAI 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 状态字段 // 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false', isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active', status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false', schedulable: accountData.schedulable !== false ? 'true' : 'false',
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(accountData.subscriptionExpiresAt || ''),
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
} }
@@ -166,10 +157,7 @@ async function createAccount(accountData) {
} }
logger.info(`Created Azure OpenAI account: ${accountId}`) logger.info(`Created Azure OpenAI account: ${accountId}`)
return { return account
...account,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}
} }
// 获取账户 // 获取账户
@@ -204,11 +192,6 @@ async function getAccount(accountId) {
} }
} }
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData return accountData
} }
@@ -240,11 +223,10 @@ async function updateAccount(accountId, updates) {
: JSON.stringify(updates.supportedModels) : JSON.stringify(updates.supportedModels)
} }
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { // ✅ 直接保存 subscriptionExpiresAt(如果提供)
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) // Azure OpenAI 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { if (updates.subscriptionExpiresAt !== undefined) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) // 直接保存,不做任何调整
delete updates.expiresAt
} }
// 更新账户类型时处理共享账户集合 // 更新账户类型时处理共享账户集合
@@ -273,10 +255,6 @@ async function updateAccount(accountId, updates) {
} }
} }
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount return updatedAccount
} }
@@ -337,7 +315,10 @@ async function getAllAccounts() {
...accountData, ...accountData,
isActive: accountData.isActive === 'true', isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false', schedulable: accountData.schedulable !== 'false',
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
platform: 'azure-openai'
}) })
} }
} }
@@ -365,6 +346,19 @@ async function getSharedAccounts() {
return accounts return accounts
} }
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 选择可用账户 // 选择可用账户
async function selectAvailableAccount(sessionId = null) { async function selectAvailableAccount(sessionId = null) {
// 如果有会话ID尝试获取之前分配的账户 // 如果有会话ID尝试获取之前分配的账户
@@ -386,9 +380,17 @@ async function selectAvailableAccount(sessionId = null) {
const sharedAccounts = await getSharedAccounts() const sharedAccounts = await getSharedAccounts()
// 过滤出可用的账户 // 过滤出可用的账户
const availableAccounts = sharedAccounts.filter( const availableAccounts = sharedAccounts.filter((acc) => {
(acc) => acc.isActive === 'true' && acc.schedulable === 'true' // ✅ 检查账户订阅是否过期
) if (isSubscriptionExpired(acc)) {
logger.debug(
`⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}`
)
return false
}
return acc.isActive === 'true' && acc.schedulable === 'true'
})
if (availableAccounts.length === 0) { if (availableAccounts.length === 0) {
throw new Error('No available Azure OpenAI accounts') throw new Error('No available Azure OpenAI accounts')

View File

@@ -6,19 +6,6 @@ const config = require('../../config/config')
const bedrockRelayService = require('./bedrockRelayService') const bedrockRelayService = require('./bedrockRelayService')
const LRUCache = require('../utils/lruCache') 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 { class BedrockAccountService {
constructor() { constructor() {
// 加密相关常量 // 加密相关常量
@@ -53,8 +40,7 @@ class BedrockAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
credentialType = 'default', // 'default', 'access_key', 'bearer_token' credentialType = 'default' // 'default', 'access_key', 'bearer_token'
subscriptionExpiresAt = null
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -70,7 +56,11 @@ class BedrockAccountService {
priority, priority,
schedulable, schedulable,
credentialType, credentialType,
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt),
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Bedrock 使用 AWS 凭证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
type: 'bedrock' // 标识这是Bedrock账户 type: 'bedrock' // 标识这是Bedrock账户
@@ -99,7 +89,6 @@ class BedrockAccountService {
priority, priority,
schedulable, schedulable,
credentialType, credentialType,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
createdAt: accountData.createdAt, createdAt: accountData.createdAt,
type: 'bedrock' type: 'bedrock'
} }
@@ -122,11 +111,6 @@ class BedrockAccountService {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
} }
account.subscriptionExpiresAt =
account.subscriptionExpiresAt && account.subscriptionExpiresAt !== ''
? account.subscriptionExpiresAt
: null
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
return { return {
@@ -163,12 +147,15 @@ class BedrockAccountService {
priority: account.priority, priority: account.priority,
schedulable: account.schedulable, schedulable: account.schedulable,
credentialType: account.credentialType, credentialType: account.credentialType,
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: account.subscriptionExpiresAt || null,
createdAt: account.createdAt, createdAt: account.createdAt,
updatedAt: account.updatedAt, updatedAt: account.updatedAt,
type: 'bedrock', type: 'bedrock',
hasCredentials: !!account.awsCredentials, platform: 'bedrock',
expiresAt: account.expiresAt || null, hasCredentials: !!account.awsCredentials
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}) })
} }
} }
@@ -234,14 +221,6 @@ class BedrockAccountService {
account.credentialType = updates.credentialType 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凭证 // 更新AWS凭证
if (updates.awsCredentials !== undefined) { if (updates.awsCredentials !== undefined) {
if (updates.awsCredentials) { if (updates.awsCredentials) {
@@ -256,6 +235,12 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
} }
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
account.subscriptionExpiresAt = updates.subscriptionExpiresAt
}
account.updatedAt = new Date().toISOString() account.updatedAt = new Date().toISOString()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
@@ -276,9 +261,7 @@ class BedrockAccountService {
schedulable: account.schedulable, schedulable: account.schedulable,
credentialType: account.credentialType, credentialType: account.credentialType,
updatedAt: account.updatedAt, updatedAt: account.updatedAt,
type: 'bedrock', type: 'bedrock'
expiresAt: account.expiresAt || null,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
} }
} }
} catch (error) { } catch (error) {
@@ -315,9 +298,17 @@ class BedrockAccountService {
return { success: false, error: 'Failed to get accounts' } return { success: false, error: 'Failed to get accounts' }
} }
const availableAccounts = accountsResult.data.filter( const availableAccounts = accountsResult.data.filter((account) => {
(account) => account.isActive && account.schedulable // ✅ 检查账户订阅是否过期
) if (this.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}`
)
return false
}
return account.isActive && account.schedulable
})
if (availableAccounts.length === 0) { if (availableAccounts.length === 0) {
return { success: false, error: 'No available Bedrock accounts' } return { success: false, error: 'No available Bedrock accounts' }
@@ -385,6 +376,19 @@ class BedrockAccountService {
} }
} }
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 🔑 生成加密密钥(缓存优化) // 🔑 生成加密密钥(缓存优化)
_generateEncryptionKey() { _generateEncryptionKey() {
if (!this._encryptionKeyCache) { if (!this._encryptionKeyCache) {

View File

@@ -6,19 +6,6 @@ const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const LRUCache = require('../utils/lruCache') 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 { class CcrAccountService {
constructor() { constructor() {
// 加密相关常量 // 加密相关常量
@@ -62,8 +49,7 @@ class CcrAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00' // 额度重置时间HH:mm格式
subscriptionExpiresAt = null
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -90,6 +76,11 @@ class CcrAccountService {
proxy: proxy ? JSON.stringify(proxy) : '', proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(), isActive: isActive.toString(),
accountType, accountType,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意CCR 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
status: 'active', status: 'active',
@@ -105,8 +96,7 @@ class CcrAccountService {
// 使用与统计一致的时区日期,避免边界问题 // 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间 quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间 quotaStoppedAt: '' // 因额度停用的时间
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -142,8 +132,7 @@ class CcrAccountService {
dailyUsage: 0, dailyUsage: 0,
lastResetDate: accountData.lastResetDate, lastResetDate: accountData.lastResetDate,
quotaResetTime, quotaResetTime,
quotaStoppedAt: null, quotaStoppedAt: null
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
} }
} }
@@ -181,14 +170,16 @@ class CcrAccountService {
errorMessage: accountData.errorMessage, errorMessage: accountData.errorMessage,
rateLimitInfo, rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度 schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
// 额度管理相关 // 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'), dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'), dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '', lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00', quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null, quotaStoppedAt: accountData.quotaStoppedAt || null
expiresAt: accountData.expiresAt || null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
}) })
} }
} }
@@ -243,11 +234,6 @@ class CcrAccountService {
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` `[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 return accountData
} }
@@ -311,12 +297,10 @@ class CcrAccountService {
updatedData.quotaResetTime = updates.quotaResetTime updatedData.quotaResetTime = updates.quotaResetTime
} }
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { // ✅ 直接保存 subscriptionExpiresAt(如果提供)
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( // CCR 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
updates.subscriptionExpiresAt if (updates.subscriptionExpiresAt !== undefined) {
) updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
} }
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
@@ -929,6 +913,19 @@ class CcrAccountService {
throw error throw error
} }
} }
/**
* ⏰ 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
} }
module.exports = new CcrAccountService() module.exports = new CcrAccountService()

View File

@@ -787,13 +787,13 @@ class ClaudeAccountService {
} }
/** /**
* 检查账户是否过期 * 检查账户订阅是否过期
* @param {Object} account - 账户对象 * @param {Object} account - 账户对象
* @returns {boolean} - 如果未设置过期时间或未过期返回 true * @returns {boolean} - true: 已过期, false: 未过期
*/ */
isAccountNotExpired(account) { isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) { if (!account.subscriptionExpiresAt) {
return true // 未设置过期时间,视为永不过期 return false // 未设置过期时间,视为永不过期
} }
const expiryDate = new Date(account.subscriptionExpiresAt) const expiryDate = new Date(account.subscriptionExpiresAt)
@@ -803,10 +803,10 @@ class ClaudeAccountService {
logger.debug( logger.debug(
`⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
) )
return false return true
} }
return true return false
} }
// 🎯 智能选择可用账户支持sticky会话和模型过滤 // 🎯 智能选择可用账户支持sticky会话和模型过滤
@@ -819,7 +819,7 @@ class ClaudeAccountService {
account.isActive === 'true' && account.isActive === 'true' &&
account.status !== 'error' && account.status !== 'error' &&
account.schedulable !== 'false' && account.schedulable !== 'false' &&
this.isAccountNotExpired(account) !this.isSubscriptionExpired(account)
) )
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
@@ -915,7 +915,7 @@ class ClaudeAccountService {
boundAccount.isActive === 'true' && boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' && boundAccount.status !== 'error' &&
boundAccount.schedulable !== 'false' && boundAccount.schedulable !== 'false' &&
this.isAccountNotExpired(boundAccount) !this.isSubscriptionExpired(boundAccount)
) { ) {
logger.info( logger.info(
`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
@@ -937,7 +937,7 @@ class ClaudeAccountService {
account.status !== 'error' && account.status !== 'error' &&
account.schedulable !== 'false' && account.schedulable !== 'false' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this.isAccountNotExpired(account) !this.isSubscriptionExpired(account)
) )
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号

View File

@@ -6,19 +6,6 @@ const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const LRUCache = require('../utils/lruCache') 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 { class ClaudeConsoleAccountService {
constructor() { constructor() {
// 加密相关常量 // 加密相关常量
@@ -65,8 +52,7 @@ class ClaudeConsoleAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00' // 额度重置时间HH:mm格式
subscriptionExpiresAt = null
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -97,6 +83,11 @@ class ClaudeConsoleAccountService {
lastUsedAt: '', lastUsedAt: '',
status: 'active', status: 'active',
errorMessage: '', errorMessage: '',
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Claude Console 没有 OAuth token因此没有 expiresAttoken过期
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
// 限流相关 // 限流相关
rateLimitedAt: '', rateLimitedAt: '',
rateLimitStatus: '', rateLimitStatus: '',
@@ -108,8 +99,7 @@ class ClaudeConsoleAccountService {
// 使用与统计一致的时区日期,避免边界问题 // 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间 quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间 quotaStoppedAt: '' // 因额度停用的时间
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -145,8 +135,7 @@ class ClaudeConsoleAccountService {
dailyUsage: 0, dailyUsage: 0,
lastResetDate: accountData.lastResetDate, lastResetDate: accountData.lastResetDate,
quotaResetTime, quotaResetTime,
quotaStoppedAt: null, quotaStoppedAt: null
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
} }
} }
@@ -184,14 +173,16 @@ class ClaudeConsoleAccountService {
errorMessage: accountData.errorMessage, errorMessage: accountData.errorMessage,
rateLimitInfo, rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度 schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
// 额度管理相关 // 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'), dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'), dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '', lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00', quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null, quotaStoppedAt: accountData.quotaStoppedAt || null
expiresAt: accountData.expiresAt || null,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
}) })
} }
} }
@@ -242,11 +233,6 @@ class ClaudeConsoleAccountService {
accountData.proxy = JSON.parse(accountData.proxy) accountData.proxy = JSON.parse(accountData.proxy)
} }
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
logger.debug( logger.debug(
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
) )
@@ -341,12 +327,10 @@ class ClaudeConsoleAccountService {
updatedData.quotaStoppedAt = updates.quotaStoppedAt updatedData.quotaStoppedAt = updates.quotaStoppedAt
} }
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { // ✅ 直接保存 subscriptionExpiresAt(如果提供)
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( // Claude Console 没有 token 刷新逻辑,不会覆盖此字段
updates.subscriptionExpiresAt if (updates.subscriptionExpiresAt !== undefined) {
) updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
} }
// 处理账户类型变更 // 处理账户类型变更
@@ -1270,6 +1254,19 @@ class ClaudeConsoleAccountService {
throw error throw error
} }
} }
/**
* ⏰ 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
} }
module.exports = new ClaudeConsoleAccountService() module.exports = new ClaudeConsoleAccountService()

View File

@@ -794,7 +794,11 @@ class DroidAccountService {
description, description,
refreshToken: this._encryptSensitiveData(normalizedRefreshToken), refreshToken: this._encryptSensitiveData(normalizedRefreshToken),
accessToken: this._encryptSensitiveData(normalizedAccessToken), accessToken: this._encryptSensitiveData(normalizedAccessToken),
expiresAt: normalizedExpiresAt || '', expiresAt: normalizedExpiresAt || '', // OAuth Token 过期时间(技术字段,自动刷新)
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
proxy: proxy ? JSON.stringify(proxy) : '', proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(), isActive: isActive.toString(),
accountType, accountType,
@@ -880,6 +884,11 @@ class DroidAccountService {
accessToken: account.accessToken accessToken: account.accessToken
? maskToken(this._decryptSensitiveData(account.accessToken)) ? maskToken(this._decryptSensitiveData(account.accessToken))
: '', : '',
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: account.subscriptionExpiresAt || null,
platform: account.platform || 'droid',
apiKeyCount: (() => { apiKeyCount: (() => {
const parsedCount = this._parseApiKeyEntries(account.apiKeys).length const parsedCount = this._parseApiKeyEntries(account.apiKeys).length
if (account.apiKeyCount === undefined || account.apiKeyCount === null) { if (account.apiKeyCount === undefined || account.apiKeyCount === null) {
@@ -1020,6 +1029,12 @@ class DroidAccountService {
} }
} }
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt直接保存
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
if (sanitizedUpdates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
if (sanitizedUpdates.proxy === undefined) { if (sanitizedUpdates.proxy === undefined) {
sanitizedUpdates.proxy = account.proxy || '' sanitizedUpdates.proxy = account.proxy || ''
} }
@@ -1374,6 +1389,19 @@ class DroidAccountService {
return hoursSinceRefresh >= this.refreshIntervalHours return hoursSinceRefresh >= this.refreshIntervalHours
} }
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
/** /**
* 获取有效的 access token自动刷新 * 获取有效的 access token自动刷新
*/ */
@@ -1419,6 +1447,14 @@ class DroidAccountService {
const isSchedulable = this._isTruthy(account.schedulable) const isSchedulable = this._isTruthy(account.schedulable)
const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
// ✅ 检查账户订阅是否过期
if (this.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Droid account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
return false
}
if (!isActive || !isSchedulable || status !== 'active') { if (!isActive || !isSchedulable || status !== 'active') {
return false return false
} }

View File

@@ -42,19 +42,6 @@ function generateEncryptionKey() {
return _encryptionKeyCache 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 账户键前缀 // Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
@@ -346,10 +333,6 @@ async function createAccount(accountData) {
let refreshToken = '' let refreshToken = ''
let expiresAt = '' let expiresAt = ''
const subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
accountData.subscriptionExpiresAt || ''
)
if (accountData.geminiOauth || accountData.accessToken) { if (accountData.geminiOauth || accountData.accessToken) {
// 如果提供了完整的 OAuth 数据 // 如果提供了完整的 OAuth 数据
if (accountData.geminiOauth) { if (accountData.geminiOauth) {
@@ -401,10 +384,13 @@ async function createAccount(accountData) {
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '', geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
accessToken: accessToken ? encrypt(accessToken) : '', accessToken: accessToken ? encrypt(accessToken) : '',
refreshToken: refreshToken ? encrypt(refreshToken) : '', refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt, expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
// 只有OAuth方式才有scopes手动添加的没有 // 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 代理设置 // 代理设置
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '', proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
@@ -421,8 +407,7 @@ async function createAccount(accountData) {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lastUsedAt: '', lastUsedAt: '',
lastRefreshAt: '', lastRefreshAt: ''
subscriptionExpiresAt
} }
// 保存到 Redis // 保存到 Redis
@@ -446,10 +431,6 @@ async function createAccount(accountData) {
} }
} }
if (!returnAccount.subscriptionExpiresAt) {
returnAccount.subscriptionExpiresAt = null
}
return returnAccount return returnAccount
} }
@@ -486,10 +467,6 @@ async function getAccount(accountId) {
// 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致) // 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致)
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true只有明确设置为'false'才为false accountData.schedulable = accountData.schedulable !== 'false' // 默认为true只有明确设置为'false'才为false
if (!accountData.subscriptionExpiresAt) {
accountData.subscriptionExpiresAt = null
}
return accountData return accountData
} }
@@ -503,10 +480,6 @@ async function updateAccount(accountId, updates) {
const now = new Date().toISOString() const now = new Date().toISOString()
updates.updatedAt = now updates.updatedAt = now
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
}
// 检查是否新增了 refresh token // 检查是否新增了 refresh token
// existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回) // existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回)
const oldRefreshToken = existingAccount.refreshToken || '' const oldRefreshToken = existingAccount.refreshToken || ''
@@ -551,15 +524,23 @@ async function updateAccount(accountId, updates) {
} }
} }
// 如果新增了 refresh token更新过期时间为10分钟 // ✅ 关键:如果新增了 refresh token更新 token 过期时间
// 不要覆盖 subscriptionExpiresAt
if (needUpdateExpiry) { if (needUpdateExpiry) {
const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString() const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
updates.expiresAt = newExpiry updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间
// ⚠️ 重要:不要修改 subscriptionExpiresAt
logger.info( logger.info(
`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes` `🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes`
) )
} }
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt直接保存
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
if (updates.geminiOauth && !oldRefreshToken) { if (updates.geminiOauth && !oldRefreshToken) {
const oauthData = const oauthData =
@@ -616,10 +597,6 @@ async function updateAccount(accountId, updates) {
} }
} }
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount return updatedAccount
} }
@@ -683,7 +660,11 @@ async function getAllAccounts() {
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// ✅ 前端显示订阅过期时间(业务字段)
// 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt
expiresAt: accountData.subscriptionExpiresAt || null,
// 添加 scopes 字段用于判断认证方式 // 添加 scopes 字段用于判断认证方式
// 处理空字符串和默认值的情况 // 处理空字符串和默认值的情况
scopes: scopes:
@@ -762,8 +743,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
for (const accountId of sharedAccountIds) { for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId) const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) { if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account)
) {
availableAccounts.push(account) availableAccounts.push(account)
} else if (account && isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
} }
} }
@@ -818,6 +808,19 @@ function isTokenExpired(account) {
return now >= expiryTime - buffer return now >= expiryTime - buffer
} }
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 检查账户是否被限流 // 检查账户是否被限流
function isRateLimited(account) { function isRateLimited(account) {
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {

View File

@@ -194,19 +194,6 @@ 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) { async function refreshAccessToken(refreshToken, proxy = null) {
try { try {
@@ -347,6 +334,19 @@ function isTokenExpired(account) {
return new Date(account.expiresAt) <= new Date() return new Date(account.expiresAt) <= new Date()
} }
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 刷新账户的 access token带分布式锁 // 刷新账户的 access token带分布式锁
async function refreshAccountToken(accountId) { async function refreshAccountToken(accountId) {
let lockAcquired = false let lockAcquired = false
@@ -530,13 +530,6 @@ async function createAccount(accountData) {
// 处理账户信息 // 处理账户信息
const accountInfo = accountData.accountInfo || {} 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位十六进制字符 // 检查邮箱是否已经是加密格式包含冒号分隔的32位十六进制字符
const isEmailEncrypted = const isEmailEncrypted =
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
@@ -573,8 +566,13 @@ async function createAccount(accountData) {
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
// 过期时间 // 过期时间
expiresAt: tokenExpiresAt, expiresAt: oauthData.expires_in
subscriptionExpiresAt, ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段)
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 状态字段 // 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false', isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active', status: 'active',
@@ -599,10 +597,7 @@ async function createAccount(accountData) {
} }
logger.info(`Created OpenAI account: ${accountId}`) logger.info(`Created OpenAI account: ${accountId}`)
return { return account
...account,
subscriptionExpiresAt: account.subscriptionExpiresAt || null
}
} }
// 获取账户 // 获取账户
@@ -645,11 +640,6 @@ async function getAccount(accountId) {
} }
} }
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData return accountData
} }
@@ -683,16 +673,18 @@ async function updateAccount(accountId, updates) {
updates.email = encrypt(updates.email) updates.email = encrypt(updates.email)
} }
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
}
// 处理代理配置 // 处理代理配置
if (updates.proxy) { if (updates.proxy) {
updates.proxy = updates.proxy =
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
} }
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt直接保存
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 更新账户类型时处理共享账户集合 // 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) { if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -719,10 +711,6 @@ async function updateAccount(accountId, updates) {
} }
} }
if (!updatedAccount.subscriptionExpiresAt) {
updatedAccount.subscriptionExpiresAt = null
}
return updatedAccount return updatedAccount
} }
@@ -805,8 +793,6 @@ async function getAllAccounts() {
} }
} }
const subscriptionExpiresAt = accountData.subscriptionExpiresAt || null
// 不解密敏感字段,只返回基本信息 // 不解密敏感字段,只返回基本信息
accounts.push({ accounts.push({
...accountData, ...accountData,
@@ -815,13 +801,16 @@ async function getAllAccounts() {
openaiOauth: maskedOauth, openaiOauth: maskedOauth,
accessToken: maskedAccessToken, accessToken: maskedAccessToken,
refreshToken: maskedRefreshToken, refreshToken: maskedRefreshToken,
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
// 添加 scopes 字段用于判断认证方式 // 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况 // 处理空字符串的情况
scopes: scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记 // 添加 hasRefreshToken 标记
hasRefreshToken: hasRefreshTokenFlag, hasRefreshToken: hasRefreshTokenFlag,
subscriptionExpiresAt,
// 添加限流状态信息(统一格式) // 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo rateLimitStatus: rateLimitInfo
? { ? {
@@ -940,8 +929,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
for (const accountId of sharedAccountIds) { for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId) const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) { if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account)
) {
availableAccounts.push(account) availableAccounts.push(account)
} else if (account && isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
} }
} }

View File

@@ -5,19 +5,6 @@ const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const LRUCache = require('../utils/lruCache') 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 { class OpenAIResponsesAccountService {
constructor() { constructor() {
// 加密相关常量 // 加密相关常量
@@ -62,8 +49,7 @@ class OpenAIResponsesAccountService {
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00', // 额度重置时间HH:mm格式
rateLimitDuration = 60, // 限流时间(分钟) rateLimitDuration = 60 // 限流时间(分钟)
subscriptionExpiresAt = null
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -89,6 +75,11 @@ class OpenAIResponsesAccountService {
isActive: isActive.toString(), isActive: isActive.toString(),
accountType, accountType,
schedulable: schedulable.toString(), schedulable: schedulable.toString(),
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意OpenAI-Responses 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
status: 'active', status: 'active',
@@ -102,8 +93,7 @@ class OpenAIResponsesAccountService {
dailyUsage: '0', dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(), lastResetDate: redis.getDateStringInTimezone(),
quotaResetTime, quotaResetTime,
quotaStoppedAt: '', quotaStoppedAt: ''
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
} }
// 保存到 Redis // 保存到 Redis
@@ -113,7 +103,6 @@ class OpenAIResponsesAccountService {
return { return {
...accountData, ...accountData,
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
apiKey: '***' // 返回时隐藏敏感信息 apiKey: '***' // 返回时隐藏敏感信息
} }
} }
@@ -140,11 +129,6 @@ class OpenAIResponsesAccountService {
} }
} }
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
return accountData return accountData
} }
@@ -172,11 +156,10 @@ class OpenAIResponsesAccountService {
: updates.baseApi : updates.baseApi
} }
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { // ✅ 直接保存 subscriptionExpiresAt(如果提供)
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) // OpenAI-Responses 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { if (updates.subscriptionExpiresAt !== undefined) {
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) // 直接保存,不做任何调整
delete updates.expiresAt
} }
// 更新 Redis // 更新 Redis
@@ -240,6 +223,10 @@ class OpenAIResponsesAccountService {
// 转换 isActive 字段为布尔值 // 转换 isActive 字段为布尔值
account.isActive = account.isActive === 'true' account.isActive = account.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
account.expiresAt = account.subscriptionExpiresAt || null
account.platform = account.platform || 'openai-responses'
accounts.push(account) accounts.push(account)
} }
} }
@@ -285,10 +272,10 @@ class OpenAIResponsesAccountService {
accountData.schedulable = accountData.schedulable !== 'false' accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值 // 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true' accountData.isActive = accountData.isActive === 'true'
accountData.subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' // ✅ 前端显示订阅过期时间(业务字段)
? accountData.subscriptionExpiresAt accountData.expiresAt = accountData.subscriptionExpiresAt || null
: null accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData) accounts.push(accountData)
} }
@@ -536,6 +523,25 @@ class OpenAIResponsesAccountService {
return { success: true, message: 'Account status reset successfully' } return { success: true, message: 'Account status reset successfully' }
} }
// ⏰ 检查账户订阅是否已过期
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置过期时间,视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
const now = new Date()
if (expiryDate <= now) {
logger.debug(
`⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}`
)
return true
}
return false
}
// 获取限流信息 // 获取限流信息
_getRateLimitInfo(accountData) { _getRateLimitInfo(accountData) {
if (accountData.rateLimitStatus !== 'limited') { if (accountData.rateLimitStatus !== 'limited') {

View File

@@ -545,6 +545,14 @@ class UnifiedClaudeScheduler {
continue continue
} }
// 检查订阅是否过期
if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Claude Console account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
)
continue
}
// 主动触发一次额度检查,确保状态即时生效 // 主动触发一次额度检查,确保状态即时生效
try { try {
await claudeConsoleAccountService.checkQuotaUsage(account.id) await claudeConsoleAccountService.checkQuotaUsage(account.id)
@@ -642,6 +650,14 @@ class UnifiedClaudeScheduler {
continue continue
} }
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
)
continue
}
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
@@ -774,6 +790,13 @@ class UnifiedClaudeScheduler {
) { ) {
return false return false
} }
// 检查订阅是否过期
if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Claude Console account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
)
return false
}
// 检查是否超额 // 检查是否超额
try { try {
await claudeConsoleAccountService.checkQuotaUsage(accountId) await claudeConsoleAccountService.checkQuotaUsage(accountId)
@@ -832,6 +855,13 @@ class UnifiedClaudeScheduler {
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
return false return false
} }
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
)
return false
}
// 检查是否超额 // 检查是否超额
try { try {
await ccrAccountService.checkQuotaUsage(accountId) await ccrAccountService.checkQuotaUsage(accountId)
@@ -1353,6 +1383,14 @@ class UnifiedClaudeScheduler {
continue continue
} }
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
)
continue
}
// 检查是否被限流或超额 // 检查是否被限流或超额
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)

View File

@@ -211,6 +211,15 @@ class UnifiedOpenAIScheduler {
error.statusCode = 403 // Forbidden - 调度被禁止 error.statusCode = 403 // Forbidden - 调度被禁止
throw error throw error
} }
// ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) {
const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 403 // Forbidden - 订阅已过期
throw error
}
} }
// 专属账户可选的模型检查只有明确配置了supportedModels且不为空才检查 // 专属账户可选的模型检查只有明确配置了supportedModels且不为空才检查
@@ -461,6 +470,14 @@ class UnifiedOpenAIScheduler {
} }
} }
// ⏰ 检查订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏭️ Skipping OpenAI-Responses account ${account.name} - subscription expired`
)
continue
}
// OpenAI-Responses 账户默认支持所有模型 // OpenAI-Responses 账户默认支持所有模型
// 因为它们是第三方兼容 API模型支持由第三方决定 // 因为它们是第三方兼容 API模型支持由第三方决定
@@ -536,6 +553,11 @@ class UnifiedOpenAIScheduler {
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
return false return false
} }
// ⏰ 检查订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
logger.info(`🚫 OpenAI-Responses account ${accountId} subscription expired`)
return false
}
// 检查并清除过期的限流状态 // 检查并清除过期的限流状态
const isRateLimitCleared = const isRateLimitCleared =
await openaiResponsesAccountService.checkAndClearRateLimit(accountId) await openaiResponsesAccountService.checkAndClearRateLimit(accountId)

View File

@@ -304,7 +304,25 @@ const selectQuickOption = (value) => {
// 更新自定义过期时间 // 更新自定义过期时间
const updateCustomExpiryPreview = () => { const updateCustomExpiryPreview = () => {
if (localForm.customExpireDate) { if (localForm.customExpireDate) {
localForm.expiresAt = new Date(localForm.customExpireDate).toISOString() try {
// 手动解析日期时间字符串,确保它被正确解释为本地时间
const [datePart, timePart] = localForm.customExpireDate.split('T')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes] = timePart.split(':').map(Number)
// 使用构造函数创建本地时间的 Date 对象,然后转换为 UTC ISO 字符串
const localDate = new Date(year, month - 1, day, hours, minutes, 0, 0)
// 验证日期有效性
if (isNaN(localDate.getTime())) {
console.error('Invalid date:', localForm.customExpireDate)
return
}
localForm.expiresAt = localDate.toISOString()
} catch (error) {
console.error('Failed to parse custom expire date:', error)
}
} }
} }

View File

@@ -3728,47 +3728,54 @@ const closeAccountExpiryEdit = () => {
editingExpiryAccount.value = null editingExpiryAccount.value = null
} }
// 根据账户平台解析更新端点
const resolveAccountUpdateEndpoint = (account) => {
switch (account.platform) {
case 'claude':
return `/admin/claude-accounts/${account.id}`
case 'claude-console':
return `/admin/claude-console-accounts/${account.id}`
case 'bedrock':
return `/admin/bedrock-accounts/${account.id}`
case 'openai':
return `/admin/openai-accounts/${account.id}`
case 'azure_openai':
return `/admin/azure-openai-accounts/${account.id}`
case 'openai-responses':
return `/admin/openai-responses-accounts/${account.id}`
case 'ccr':
return `/admin/ccr-accounts/${account.id}`
case 'gemini':
return `/admin/gemini-accounts/${account.id}`
case 'droid':
return `/admin/droid-accounts/${account.id}`
default:
throw new Error(`Unsupported platform: ${account.platform}`)
}
}
// 保存账户过期时间 // 保存账户过期时间
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => { const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
try { try {
// 找到对应的账户以获取平台信息 // 根据账号平台选择正确的 API 端点
const account = accounts.value.find((acc) => acc.id === accountId) const account = accounts.value.find((acc) => acc.id === accountId)
if (!account) { if (!account) {
showToast('账户不存在', 'error') showToast('未找到账户', 'error')
if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving()
}
return return
} }
// 根据平台动态选择端点 // 定义每个平台的端点和参数名
const endpoint = resolveAccountUpdateEndpoint(account) // 注意:部分平台使用 :accountId部分使用 :id
let endpoint = ''
switch (account.platform) {
case 'claude':
case 'claude-oauth':
endpoint = `/admin/claude-accounts/${accountId}`
break
case 'gemini':
endpoint = `/admin/gemini-accounts/${accountId}`
break
case 'claude-console':
endpoint = `/admin/claude-console-accounts/${accountId}`
break
case 'bedrock':
endpoint = `/admin/bedrock-accounts/${accountId}`
break
case 'ccr':
endpoint = `/admin/ccr-accounts/${accountId}`
break
case 'openai':
endpoint = `/admin/openai-accounts/${accountId}` // 使用 :id
break
case 'droid':
endpoint = `/admin/droid-accounts/${accountId}` // 使用 :id
break
case 'azure_openai':
endpoint = `/admin/azure-openai-accounts/${accountId}` // 使用 :id
break
case 'openai-responses':
endpoint = `/admin/openai-responses-accounts/${accountId}` // 使用 :id
break
default:
showToast(`不支持的平台类型: ${account.platform}`, 'error')
return
}
const data = await apiClient.put(endpoint, { const data = await apiClient.put(endpoint, {
expiresAt: expiresAt || null expiresAt: expiresAt || null
}) })
@@ -3786,7 +3793,8 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
} }
} }
} catch (error) { } catch (error) {
showToast(error.message || '更新失败', 'error') console.error('更新账户过期时间失败:', error)
showToast('更新失败', 'error')
// 重置保存状态 // 重置保存状态
if (expiryEditModalRef.value) { if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving() expiryEditModalRef.value.resetSaving()
@@ -3802,6 +3810,7 @@ onMounted(() => {
<style scoped> <style scoped>
.table-container { .table-container {
overflow-x: auto;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
} }
@@ -3835,6 +3844,12 @@ onMounted(() => {
min-height: calc(100vh - 300px); min-height: calc(100vh - 300px);
} }
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row { .table-row {
transition: all 0.2s ease; transition: all 0.2s ease;
} }