Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(openai): 增加反代开关并限制 Codex 仅使用启用账号
- 为 OpenAI 账号模型、数据库迁移和存储映射新增 reverse_proxy_enabled 字段,并保持默认开启以兼容旧数据
- 调整 Codex 账号池筛选逻辑,仅使用启用反代的 OAuth 账号,并在 Single 策略下为不可用选中账号增加可用账号回退
- 为 OpenAI 账号管理页补充单条和批量反代操作,统一通过现有更新/保存流程落库并刷新 Codex 池
- 优化卡片和表格视图中的反代交互,将状态切换收敛到更多菜单的动态动作项并补充中英文文案
  • Loading branch information
flyhelanman committed Apr 12, 2026
commit 3e24c107295348725ce33e109b3545f34e0f4b07
24 changes: 24 additions & 0 deletions src-tauri/src/data/database/openai/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub async fn create_tables(
CREATE TABLE IF NOT EXISTS openai_accounts (
id VARCHAR(255) PRIMARY KEY,
email TEXT NOT NULL,
reverse_proxy_enabled BOOLEAN NOT NULL DEFAULT TRUE,
access_token TEXT NOT NULL,
refresh_token TEXT,
id_token TEXT,
Expand Down Expand Up @@ -259,6 +260,29 @@ pub async fn add_new_fields_if_not_exist(
println!("Added column rt_invalid_reason to openai_accounts");
}

let check_reverse_proxy_enabled = client
.query_one(
"SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'openai_accounts'
AND column_name = 'reverse_proxy_enabled'
)",
&[],
)
.await?;

let reverse_proxy_enabled_exists: bool = check_reverse_proxy_enabled.get(0);
if !reverse_proxy_enabled_exists {
client
.execute(
"ALTER TABLE openai_accounts ADD COLUMN reverse_proxy_enabled BOOLEAN NOT NULL DEFAULT TRUE",
&[],
)
.await?;
println!("Added column reverse_proxy_enabled to openai_accounts");
}

// 添加 API 账号字段
for (column, data_type) in &api_columns {
let check_column = client
Expand Down
11 changes: 7 additions & 4 deletions src-tauri/src/data/storage/openai/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ impl AccountDbMapper<Account> for OpenAIAccountMapper {
Ok(Account {
id: row.get(0),
email: row.get(1),
reverse_proxy_enabled: row.try_get(37).unwrap_or(true),
account_type,
token,
api_config,
Expand Down Expand Up @@ -122,7 +123,7 @@ impl AccountDbMapper<Account> for OpenAIAccountMapper {
codex_7d_used_percent, codex_7d_reset_after_seconds, codex_7d_window_minutes, \
codex_primary_over_secondary_percent, codex_usage_updated_at, \
account_type, model_provider, model, model_reasoning_effort, wire_api, base_url, api_key, \
openai_auth_json, is_forbidden, rt_invalid, rt_invalid_reason"
openai_auth_json, is_forbidden, rt_invalid, rt_invalid_reason, reverse_proxy_enabled"
}

fn insert_sql() -> &'static str {
Expand All @@ -133,8 +134,8 @@ impl AccountDbMapper<Account> for OpenAIAccountMapper {
version, deleted, tag, tag_color, codex_5h_used_percent, codex_5h_reset_after_seconds, codex_5h_window_minutes,
codex_7d_used_percent, codex_7d_reset_after_seconds, codex_7d_window_minutes,
codex_primary_over_secondary_percent, codex_usage_updated_at, account_type,
model_provider, model, model_reasoning_effort, wire_api, base_url, api_key, openai_auth_json, is_forbidden, rt_invalid, rt_invalid_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37)
model_provider, model, model_reasoning_effort, wire_api, base_url, api_key, openai_auth_json, is_forbidden, rt_invalid, rt_invalid_reason, reverse_proxy_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
access_token = EXCLUDED.access_token,
Expand Down Expand Up @@ -170,7 +171,8 @@ impl AccountDbMapper<Account> for OpenAIAccountMapper {
openai_auth_json = EXCLUDED.openai_auth_json,
is_forbidden = EXCLUDED.is_forbidden,
rt_invalid = EXCLUDED.rt_invalid,
rt_invalid_reason = EXCLUDED.rt_invalid_reason
rt_invalid_reason = EXCLUDED.rt_invalid_reason,
reverse_proxy_enabled = EXCLUDED.reverse_proxy_enabled
"#
}

Expand Down Expand Up @@ -289,6 +291,7 @@ impl AccountDbMapper<Account> for OpenAIAccountMapper {
Box::new(is_forbidden),
Box::new(account.rt_invalid),
Box::new(account.rt_invalid_reason.clone()),
Box::new(account.reverse_proxy_enabled),
]
}
}
36 changes: 36 additions & 0 deletions src-tauri/src/platforms/openai/codex/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ impl CodexPoolAccount {
pub fn from_openai_account(
account: &crate::platforms::openai::models::Account,
) -> Option<Self> {
if !account.reverse_proxy_enabled {
return None;
}

let token = account.token.as_ref()?;

// 跳过被禁用的账号
Expand Down Expand Up @@ -323,6 +327,38 @@ pub struct DailyStats {
pub tokens: u64,
}

#[cfg(test)]
mod tests {
use super::CodexPoolAccount;
use crate::platforms::openai::models::{Account, TokenData};

fn sample_token() -> TokenData {
TokenData {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
id_token: None,
expires_in: 3600,
expires_at: chrono::Utc::now().timestamp() + 3600,
token_type: Some("Bearer".to_string()),
}
}

#[test]
fn from_openai_account_skips_accounts_with_reverse_proxy_disabled() {
let mut account = Account::new_oauth(
"user@example.com".to_string(),
sample_token(),
Some("acct".to_string()),
None,
None,
);
account.reverse_proxy_enabled = false;

let pooled = CodexPoolAccount::from_openai_account(&account);
assert!(pooled.is_none());
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyStatsResponse {
pub stats: Vec<DailyStats>,
Expand Down
64 changes: 56 additions & 8 deletions src-tauri/src/platforms/openai/codex/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,13 @@ impl CodexPool {
async fn select_single(&self, pool: &[CodexPoolAccount]) -> Option<CodexPoolAccount> {
let selected_id = self.selected_account_id.read().await.clone();

let account = if let Some(id) = selected_id {
pool.iter()
.find(|a| a.id == id && a.is_available())
.cloned()
} else {
// 没有选中账号时,使用第一个可用账号
pool.iter().find(|a| a.is_available()).cloned()
};
let account = selected_id
.as_ref()
.and_then(|id| pool.iter().find(|a| a.id == *id && a.is_available()).cloned())
.or_else(|| {
// 选中账号不可用时,回退到第一个可用账号,避免服务被无声卡死。
pool.iter().find(|a| a.is_available()).cloned()
});

if account.is_some() {
// 增加请求计数
Expand Down Expand Up @@ -360,6 +359,55 @@ impl Default for CodexPool {
}
}

#[cfg(test)]
mod tests {
use super::{CodexPool, PoolStrategy};
use crate::platforms::openai::codex::models::CodexPoolAccount;

fn sample_pool_account(id: &str) -> CodexPoolAccount {
let now = chrono::Utc::now().timestamp();
CodexPoolAccount {
id: id.to_string(),
email: format!("{id}@example.com"),
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
id_token: None,
expires_at: now + 3600,
chatgpt_account_id: id.to_string(),
chatgpt_user_id: None,
organization_id: None,
is_active: true,
is_forbidden: false,
last_used: Some(now),
last_refresh: None,
cooldown_until: None,
unavailable_reason: None,
last_error_status: None,
daily_quota: None,
used_quota: 0,
total_tokens_used: 0,
codex_5h_used_percent: None,
codex_7d_used_percent: None,
plan_type: None,
subscription_expires_at: None,
tag: None,
tag_color: None,
}
}

#[tokio::test]
async fn select_single_falls_back_to_first_available_account() {
let pool = CodexPool::new();
pool.add_account(sample_pool_account("primary")).await;
pool.add_account(sample_pool_account("fallback")).await;
pool.set_strategy(PoolStrategy::Single).await;
pool.set_selected_account_id("missing-in-pool".to_string()).await;

let selected = pool.next_account().await.unwrap();
assert_eq!(selected.id, "primary");
}
}

// ==================== Codex Server 状态 ====================

/// Codex API 服务器状态
Expand Down
57 changes: 57 additions & 0 deletions src-tauri/src/platforms/openai/models/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ use super::{QuotaData, TokenData};
use crate::data::storage::common::SyncableAccount;
use serde::{Deserialize, Serialize};

fn default_reverse_proxy_enabled() -> bool {
true
}

/// 账号类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -44,6 +48,8 @@ pub struct ApiConfig {
pub struct Account {
pub id: String,
pub email: String,
#[serde(default = "default_reverse_proxy_enabled")]
pub reverse_proxy_enabled: bool,
/// 账号类型
#[serde(default)]
pub account_type: AccountType,
Expand Down Expand Up @@ -149,6 +155,7 @@ impl Account {
Self {
id,
email,
reverse_proxy_enabled: true,
account_type: AccountType::OAuth,
token: Some(token),
api_config: None,
Expand Down Expand Up @@ -176,6 +183,7 @@ impl Account {
Self {
id,
email,
reverse_proxy_enabled: true,
account_type: AccountType::API,
token: None,
api_config: Some(api_config),
Expand Down Expand Up @@ -252,3 +260,52 @@ impl Account {
self.updated_at = chrono::Utc::now().timestamp();
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn sample_token() -> TokenData {
TokenData {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
id_token: None,
expires_in: 3600,
expires_at: chrono::Utc::now().timestamp() + 3600,
token_type: Some("Bearer".to_string()),
}
}

#[test]
fn reverse_proxy_defaults_to_true_for_legacy_account_json() {
let legacy = json!({
"id": "oauth-1",
"email": "user@example.com",
"account_type": "oauth",
"token": sample_token(),
"created_at": 1,
"last_used": 1,
"updated_at": 1,
"version": 0,
"deleted": false,
"rt_invalid": false
});

let account: Account = serde_json::from_value(legacy).unwrap();
assert!(account.reverse_proxy_enabled);
}

#[test]
fn new_oauth_account_enables_reverse_proxy_by_default() {
let account = Account::new_oauth(
"user@example.com".to_string(),
sample_token(),
Some("acct".to_string()),
None,
None,
);

assert!(account.reverse_proxy_enabled);
}
}
38 changes: 38 additions & 0 deletions src/components/openai/AccountCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@
</svg>
<span>{{ $t('accountCard.copyAccessToken') }}</span>
</button>
<button v-if="reverseProxyAction" @click="handleMenuClick('toggleReverseProxy', close)" class="dropdown-item">
<svg v-if="reverseProxyAction === 'enable'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 7h10a4 4 0 1 1 0 8H7a4 4 0 1 1 0-8zm0 2a2 2 0 0 0 0 4h10a2 2 0 0 0 0-4H7zm9 1.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 7h10a4 4 0 1 1 0 8H7a4 4 0 1 1 0-8zm0 2a2 2 0 0 0 0 4h10a2 2 0 0 0 0-4H7zm1 1.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>
<span>{{ reverseProxyActionLabel }}</span>
</button>
<button v-if="isApiAccount && account.api_config?.key" @click="handleMenuClick('copyApiKey', close)" class="dropdown-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
Expand Down Expand Up @@ -346,6 +355,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import FloatingDropdown from '../common/FloatingDropdown.vue'
import TagEditorModal from '../token/TagEditorModal.vue'
import { getReverseProxyAction, toggleReverseProxyForAccount } from '@/utils/openaiReverseProxy'

const { t: $t } = useI18n()

Expand Down Expand Up @@ -586,6 +596,31 @@ const handleTagClear = () => {
window.$notify?.success($t('messages.tagCleared'))
}

const reverseProxyAction = computed(() => getReverseProxyAction(props.account))

const reverseProxyActionLabel = computed(() => {
if (reverseProxyAction.value === 'disable') {
return $t('platform.openai.disableReverseProxy')
}
if (reverseProxyAction.value === 'enable') {
return $t('platform.openai.enableReverseProxy')
}
return ''
})

const handleReverseProxyToggle = () => {
const action = reverseProxyAction.value
const updated = toggleReverseProxyForAccount(props.account)
if (!updated) return

emit('account-updated', props.account)
window.$notify?.success(
action === 'disable'
? $t('platform.openai.reverseProxyDisabledSuccess')
: $t('platform.openai.reverseProxyEnabledSuccess')
)
}

// 复制操作
const copyEmail = async () => {
try {
Expand Down Expand Up @@ -639,6 +674,9 @@ const handleMenuClick = async (type, close) => {
case 'copyBaseUrl':
await copyBaseUrl()
break
case 'toggleReverseProxy':
handleReverseProxyToggle()
break
case 'delete':
emit('delete', props.account.id)
break
Expand Down
Loading