From 7cf99769b1f14cf984b53fa45934aa98d3b5f4fa Mon Sep 17 00:00:00 2001 From: Zyy <0x@ainz.cc> Date: Sat, 31 Oct 2026 21:19:07 +0800 Subject: [PATCH] feat(accounts): support editing optional account names (#568) Allow local account names to be changed or cleared without replacing credentials or account settings. Show the name with notes and fall back to email when the name is empty. Co-authored-by: Zyy Co-authored-by: Codex --- .../gateway-admin/src/model/accounts.rs | 2 + .../tests/use_case/accounts/mod.rs | 2 + .../src/admin/accounts/credentials.rs | 17 +- .../gateway-api/tests/admin/accounts/mod.rs | 41 +++- .../provider_accounts/admin_adapter.rs | 8 + .../postgres/provider_accounts/repository.rs | 27 ++- .../src/postgres/provider_accounts/rows.rs | 1 + .../provider_accounts/authorization.rs | 36 +++- .../tests/postgres/provider_accounts/mod.rs | 179 +++++++++++++++++- .../gateway-store/tests/postgres/proxies.rs | 1 + .../providers/openai/src/credential/oauth.rs | 5 +- .../openai/tests/credential/oauth.rs | 15 +- docs/api.md | 6 +- frontend/src/api/modules/accounts.ts | 1 + frontend/src/i18n/locales/en/accounts.json | 2 + .../src/i18n/locales/zh-Hans/accounts.json | 2 + .../src/i18n/locales/zh-Hant/accounts.json | 2 + .../accounts/components/AccountEditModal.vue | 12 +- .../components/AccountIdentityCell.vue | 32 +--- .../account-quota-forecast-modal/index.vue | 2 +- .../accounts/composables/useAccountEditor.ts | 12 ++ frontend/src/views/accounts/index.vue | 2 + .../views/accounts/utils/accountIdentity.ts | 5 +- 23 files changed, 369 insertions(+), 43 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index ad8b38333..5ad644824 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -226,6 +226,8 @@ pub struct AccountCapacity { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateAccount { pub account_id: String, + /// 缺省保留名称,空字符串清除名称并使用邮箱展示 + pub name: Option, /// 缺省保留备注;空字符串清空备注 pub notes: Option, pub enabled: bool, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index 8cb0705cd..a05a1ec03 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -1795,6 +1795,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish .update( &context("update-request"), UpdateAccount { + name: None, notes: None, model_access: Default::default(), outbound_proxy: None, @@ -1834,6 +1835,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() { .update( &context("update-failure"), UpdateAccount { + name: None, notes: None, model_access: Default::default(), outbound_proxy: None, diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index ec38e9223..2278ebaeb 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -116,7 +116,10 @@ pub struct StartAccountAuthorizationRequest { impl StartAccountAuthorizationRequest { pub fn validate(&self) -> Result<(), WireValidationError> { parse_provider(&self.provider)?; - require_text(&self.name, MAX_NAME_BYTES, "name")?; + validate_account_name(&self.name)?; + if self.account_id.is_none() && self.name.trim().is_empty() { + return Err(WireValidationError::new("name")); + } if let Some(account_id) = self.account_id.as_deref() { require_account_id(account_id, "accountId")?; } @@ -213,6 +216,7 @@ pub struct UpdateAccountRequest { pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_id: String, + pub name: Option, pub notes: Option, pub enabled: bool, #[serde(deserialize_with = "deserialize_required_nullable")] @@ -225,6 +229,9 @@ pub struct UpdateAccountRequest { impl UpdateAccountRequest { pub fn validate(&self) -> Result<(), WireValidationError> { require_account_id(&self.account_id, "accountId")?; + if let Some(name) = &self.name { + validate_account_name(name)?; + } if let Some(connection) = &self.connection { connection.validate()?; } @@ -248,6 +255,7 @@ impl UpdateAccountRequest { self.outbound_proxy_url, )?, account_id: self.account_id, + name: self.name.map(|name| name.trim().to_owned()), notes: self.notes, enabled: self.enabled, concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?, @@ -513,6 +521,13 @@ fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidatio Ok(()) } +fn validate_account_name(value: &str) -> Result<(), WireValidationError> { + if value.len() > MAX_NAME_BYTES || value.chars().any(char::is_control) { + return Err(WireValidationError::new("name")); + } + Ok(()) +} + fn require_text( value: &str, max_bytes: usize, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index ba1ebec5b..8fe1e014b 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -438,6 +438,45 @@ mod batch_update { } } +#[test] +fn single_update_should_validate_optional_names_and_accept_clearing() { + use gateway_api::admin::accounts::UpdateAccountRequest; + use serde_json::json; + + let baseline = json!({ + "accountId": "acct_name", "enabled": true, "concurrencyLimit": null, + "weight": 1, "groupIds": [] + }); + let omitted: UpdateAccountRequest = serde_json::from_value(baseline.clone()).unwrap(); + assert!(omitted.name.is_none()); + omitted.validate().unwrap(); + for (name, valid) in [ + (json!(null), true), + (json!(" 团队账号 "), true), + (json!("a".repeat(512)), true), + (json!("账".repeat(170)), true), + (json!("账".repeat(171)), false), + (json!("a".repeat(513)), false), + (json!(""), true), + (json!(" "), true), + (json!(" \t "), false), + (json!("账号\n名称"), false), + (json!("账号\u{0085}"), false), + ] { + let mut input = baseline.clone(); + input["name"] = name; + let request: UpdateAccountRequest = serde_json::from_value(input).unwrap(); + if valid { + request.validate().expect("accept optional bounded name"); + } else { + assert_eq!(request.validate().unwrap_err().field(), "name"); + } + } + let mut unknown = baseline; + unknown["unknownField"] = json!(true); + assert!(serde_json::from_value::(unknown).is_err()); +} + #[test] fn single_update_should_accept_optional_unicode_and_multiline_notes() { use gateway_api::admin::accounts::UpdateAccountRequest; @@ -732,7 +771,7 @@ mod actions { fn credential_recovery_requests_should_not_accept_client_revision_fences() { let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({ "provider": "openai", - "name": "reauthorize", + "name": "", "accountId": "acct_1" })) .expect("decode reauthorization"); diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index c9f1210ad..7d17d5c86 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -645,10 +645,14 @@ impl AccountStore for PgAdminAccountStore { if command.notes.is_some() { changed_fields.push("notes".to_owned()); } + if command.name.is_some() { + changed_fields.push("name".to_owned()); + } let config_revision = self .accounts .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { account_ids: vec![command.account_id.clone()], + name: command.name, notes: command.notes, enabled: Some(command.enabled), concurrency_limit: Some(command.concurrency_limit), @@ -750,6 +754,7 @@ impl AccountStore for PgAdminAccountStore { .accounts .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { account_ids: command.account_ids, + name: None, notes: None, enabled: command.enabled, concurrency_limit: command.concurrency_limit, @@ -983,6 +988,9 @@ pub(super) fn prepare_rotation( if settings.notes.is_some() { changed_fields.push("notes".to_owned()); } + if settings.name.is_some() { + changed_fields.push("name".to_owned()); + } } Ok(RotateProviderAccount { settings, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index ceb00866c..0e9d86775 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -266,7 +266,6 @@ impl ProviderAccountRepository for PgProviderAccountRepository { async fn update_provider_account(&self, account: UpdateProviderAccount) -> StoreResult { require_nonempty(ENTITY, "id", &account.id)?; - require_nonempty(ENTITY, "name", &account.name)?; let result = sqlx::query( "update provider_accounts set name = $2, email = $3, plan_type = $4, updated_at = greatest(now(), updated_at) @@ -547,6 +546,14 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { ) .await?; } + if let Some(name) = command.name.as_deref() { + update_provider_account_name_in_transaction( + &mut transaction, + &command.account_ids, + name, + ) + .await?; + } append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision) .await?; Ok(revision) @@ -631,6 +638,20 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { } } +async fn update_provider_account_name_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + account_ids: &[String], + name: &str, +) -> StoreResult<()> { + sqlx::query("update provider_accounts set name = $2 where id = any($1::text[])") + .bind(account_ids) + .bind(name.trim()) + .execute(&mut **transaction) + .await + .map_err(|source| postgres_unavailable("update provider account name", source))?; + Ok(()) +} + async fn update_provider_account_notes_in_transaction( transaction: &mut Transaction<'_, Postgres>, account_ids: &[String], @@ -1112,7 +1133,6 @@ pub(super) async fn rotate_provider_account_admin_in_transaction( ) -> StoreResult { command.scope.validate()?; require_nonempty(ENTITY, "account_id", &command.profile.id)?; - require_nonempty(ENTITY, "name", &command.profile.name)?; if let Some(identity) = &command.replacement_identity { require_nonempty(ENTITY, "upstream_user_id", identity.upstream_user_id())?; if let Some(account_id) = identity.upstream_account_id() { @@ -1158,6 +1178,9 @@ pub(super) async fn rotate_provider_account_admin_in_transaction( if let Some(notes) = settings.notes.as_deref() { update_provider_account_notes_in_transaction(transaction, ids, notes).await?; } + if let Some(name) = settings.name.as_deref() { + update_provider_account_name_in_transaction(transaction, ids, name).await?; + } } append_admin_audit_event_in_transaction(transaction, command.audit, config_revision).await?; Ok(ProviderAccountAdminRotation { diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index fc2ecd437..aa40b2d9f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -252,6 +252,7 @@ impl fmt::Debug for RotateProviderAccount { pub struct BatchUpdateProviderAccountsAdmin { pub outbound_proxy: Option, pub account_ids: Vec, + pub name: Option, pub notes: Option, pub enabled: Option, pub concurrency_limit: Option>, diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs index d1a2a69c6..9963755b2 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs @@ -174,13 +174,43 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e }, )), }; + let rename = UpdateAccount { + account_id: id.to_string(), + name: Some("renamed team".to_owned()), + notes: None, + enabled: true, + concurrency_limit: None, + weight: gateway_core::account::AccountWeight::DEFAULT, + model_access: None, + group_ids: vec![], + outbound_proxy: None, + }; + store + .update_account(rename.clone(), &context) + .await + .unwrap(); let committed = store .commit_authorization(command.clone(), &context) .await .unwrap(); - sqlx::query("update provider_accounts set name='later profile' where id=$1") - .bind(id.as_str()) - .execute(&database.pool) + assert_eq!( + store + .load_account(id.as_str(), Default::default()) + .await + .unwrap() + .unwrap() + .account + .name, + "renamed team" + ); + store + .update_account( + UpdateAccount { + name: Some("later profile".to_owned()), + ..rename + }, + &context, + ) .await .unwrap(); let replayed = store diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index b4a815d61..048cfc215 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -456,7 +456,7 @@ async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() { } #[tokio::test] -async fn quota_plan_changes_survive_inflight_background_and_manual_token_refresh() { +async fn profile_changes_survive_inflight_background_and_manual_token_refresh() { let Some(database) = TestDatabase::create("quota_plan_refresh").await else { return; }; @@ -466,6 +466,26 @@ async fn quota_plan_changes_survive_inflight_background_and_manual_token_refresh seed.plan_type = Some("plus".to_owned()); repository.insert_provider_account(seed).await.unwrap(); let original = repository.get_account(&id).await.unwrap().unwrap(); + admin_account_store(&database.pool) + .update_account( + UpdateAccount { + account_id: id.to_string(), + name: Some("renamed team".to_owned()), + notes: None, + enabled: true, + concurrency_limit: None, + weight: gateway_core::account::AccountWeight::DEFAULT, + model_access: None, + group_ids: vec![], + outbound_proxy: None, + }, + &MutationContext { + actor: MutationActor::System, + request_id: "rename-before-refresh".to_owned(), + }, + ) + .await + .unwrap(); let config_revision = current_revision(&database.pool).await; let observed_at = SystemTime::now(); let observation = QuotaObservation { @@ -489,7 +509,7 @@ async fn quota_plan_changes_survive_inflight_background_and_manual_token_refresh assert_eq!(upgraded.revision(), original.revision()); assert_eq!(current_revision(&database.pool).await, config_revision); - // 刷新在额度更新前已准备好旧资料,CAS 提交仍须保留数据库中的新套餐 + // 刷新已准备好旧资料,CAS 提交仍须保留数据库中的新名称和套餐 let refresh = CredentialCasUpdate::new( id.clone(), original.revision(), @@ -520,6 +540,7 @@ async fn quota_plan_changes_survive_inflight_background_and_manual_token_refresh )); let current = repository.get_account(&id).await.unwrap().unwrap(); assert_eq!(current.plan_type(), Some("pro")); + assert_eq!(current.name(), "renamed team"); let stale = QuotaObservation { expected_connection: None, plan_type: Some("free".to_owned()), @@ -548,7 +569,7 @@ async fn quota_plan_changes_survive_inflight_background_and_manual_token_refresh .unwrap(); let refreshed = repository.get_account(&id).await.unwrap().unwrap(); assert_eq!(refreshed.plan_type(), Some("pro")); - assert_eq!(refreshed.name(), original.name()); + assert_eq!(refreshed.name(), "renamed team"); assert_eq!(refreshed.email(), original.email()); let observed_at = SystemTime::now(); repository @@ -1187,6 +1208,7 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() { let result = store .update_account( UpdateAccount { + name: None, notes: None, model_access: Default::default(), outbound_proxy: None, @@ -1268,6 +1290,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit request_id: "proxy-edit".to_owned(), }; let command = UpdateAccount { + name: None, notes: None, model_access: Default::default(), account_id: "acct_proxy".to_owned(), @@ -1324,6 +1347,120 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit database.close().await; } +#[tokio::test] +async fn account_rename_preserves_facts_and_is_searchable() { + let Some(database) = TestDatabase::create("account_rename").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let mut seed = account("acct_rename", "rename-user"); + seed.name = "Original team".to_owned(); + seed.outbound_proxy = + Some(gateway_core::account::OutboundProxy::parse("http://127.0.0.1:18080").unwrap()); + repository.insert_provider_account(seed).await.unwrap(); + let facts_query = "select to_jsonb(a) - 'name' - 'updated_at' + from provider_accounts a where id = 'acct_rename'"; + let before: serde_json::Value = sqlx::query_scalar(facts_query) + .fetch_one(&database.pool) + .await + .unwrap(); + let store = admin_account_store(&database.pool); + let context = MutationContext { + actor: MutationActor::System, + request_id: "rename-account".to_owned(), + }; + let command = UpdateAccount { + account_id: "acct_rename".to_owned(), + name: Some(" 研发团队 ".to_owned()), + notes: None, + enabled: true, + concurrency_limit: None, + weight: gateway_core::account::AccountWeight::DEFAULT, + model_access: None, + group_ids: vec![], + outbound_proxy: None, + }; + let result = store + .update_account(command.clone(), &context) + .await + .unwrap(); + assert_eq!(result.config_revision.get(), 2); + let after: serde_json::Value = sqlx::query_scalar(facts_query) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(after, before); + for (search, total) in [("研发团队", 1), ("Original team", 0), ("acct_rename@", 1)] { + let page = store + .list_accounts( + AccountListQuery { + page: 1, + page_size: PageSize::new(20).unwrap(), + provider_kind: None, + group_filter: None, + search: Some(search.to_owned()), + status: None, + sort: None, + }, + Default::default(), + ) + .await + .unwrap(); + assert_eq!(page.total, total, "{search}"); + } + let fields: Vec = sqlx::query_scalar( + "select changed_fields from admin_audit_events where admin_request_id = 'rename-account'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(fields.iter().any(|field| field == "name")); + store + .update_account( + UpdateAccount { + name: None, + ..command.clone() + }, + &context, + ) + .await + .unwrap(); + assert_eq!( + store + .load_account("acct_rename", Default::default()) + .await + .unwrap() + .unwrap() + .account + .name, + "研发团队" + ); + store + .update_account( + UpdateAccount { + name: Some(" ".to_owned()), + ..command + }, + &context, + ) + .await + .unwrap(); + let cleared = store + .load_account("acct_rename", Default::default()) + .await + .unwrap() + .unwrap(); + assert_eq!(cleared.account.name, ""); + assert_eq!( + sqlx::query_scalar::<_, serde_json::Value>(facts_query) + .fetch_one(&database.pool) + .await + .unwrap(), + before + ); + database.close().await; +} + #[tokio::test] async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() { let Some(database) = TestDatabase::create("account_notes").await else { @@ -1340,6 +1477,7 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() { request_id: "notes-edit".to_owned(), }; let command = UpdateAccount { + name: None, account_id: "acct_notes".to_owned(), notes: Some(" 团队备用\n下月续费 ".to_owned()), enabled: true, @@ -1489,6 +1627,7 @@ async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() { let result = admin_account_store(&database.pool) .update_account( UpdateAccount { + name: None, account_id: "acct_notes".to_owned(), notes: Some("备".repeat(501)), enabled: false, @@ -2516,6 +2655,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let revision = repository .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { + name: None, notes: None, model_access: Default::default(), outbound_proxy: None, @@ -2584,6 +2724,7 @@ async fn credential_rotation_and_settings_share_one_transaction() { .await .expect("seed group"); let settings = UpdateAccount { + name: Some(" 团队新名称 ".to_owned()), account_id: ACCOUNT_ID.to_owned(), notes: Some("统一保存".to_owned()), enabled: false, @@ -2623,6 +2764,7 @@ async fn credential_rotation_and_settings_share_one_transaction() { assert_eq!(before["concurrency_limit"], 3); assert_eq!(before["weight"], 7); assert_eq!(before["notes"], "统一保存"); + assert_eq!(before["name"], "团队新名称"); assert_eq!( account_group_ids(&database.pool, ACCOUNT_ID).await, [GROUP_ID] @@ -2633,6 +2775,7 @@ async fn credential_rotation_and_settings_share_one_transaction() { "missing_group", 2, UpdateAccount { + name: Some(String::new()), enabled: true, group_ids: vec![ AccountGroupId::new("grp_00000000000000000000000000000092").unwrap(), @@ -2688,6 +2831,35 @@ async fn credential_rotation_and_settings_share_one_transaction() { .unwrap(); assert_eq!(audit_count, 1, "{case}"); } + for (expected_revision, name) in [(2, Some(String::new())), (3, None)] { + repository + .rotate_provider_account(RotateProviderAccount { + scope: scope.clone(), + profile: profile(ACCOUNT_ID, ""), + replacement_identity: None, + credential: credential_update(ACCOUNT_ID, expected_revision, "cleared-profile"), + settings: Some(UpdateAccount { + name, + ..settings.clone() + }), + audit: audit( + &format!("clear_name_{expected_revision}"), + "rotate", + ACCOUNT_ID, + ), + }) + .await + .unwrap(); + assert_eq!( + repository + .get_account(&ProviderAccountId::new(ACCOUNT_ID).unwrap()) + .await + .unwrap() + .unwrap() + .name(), + "" + ); + } database.close().await; } @@ -3307,6 +3479,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() { admin_account_store(&database.pool) .update_account( UpdateAccount { + name: None, notes: None, model_access: Default::default(), account_id: id.as_str().to_owned(), diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 80409513b..8f4d0d5da 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -391,6 +391,7 @@ fn success() -> ProxyTestResult { fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount { UpdateAccount { + name: None, notes: None, model_access: Default::default(), account_id: account_id.to_owned(), diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index e3e534b05..fa4236a0a 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -184,7 +184,8 @@ impl CodexPendingAuthorization { if !valid_text(&pending.flow_id) || !valid_text(&pending.owner_ref) || !valid_text(&pending.started_request_ref) - || !valid_text(&pending.name) + || !(valid_text(&pending.name) + || pending.name.is_empty() && pending.reauthorization.is_some()) || pending.expires_at <= Utc::now() || !valid_secret(pending.state.expose_secret()) || !valid_secret(pending.nonce.expose_secret()) @@ -589,7 +590,7 @@ impl CodexOAuthAdminService { reauthorization: Option, installation_id: String, ) -> Result { - if !valid_text(&name) { + if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) { return Err(CodexOAuthAdminError::InvalidInput); } let expires_at = Utc::now() diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 6867bd816..920012fc4 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -10,7 +10,9 @@ use gateway_admin::model::provider_credentials::{ AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation, }; use gateway_admin::model::{MutationActor, MutationContext}; -use gateway_core::account::{AccountStatus, ProviderAccountId}; +use gateway_core::account::{ + AccountStatus, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, +}; use gateway_core::routing::ProviderKind; use provider_openai::credential::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, @@ -403,7 +405,7 @@ async fn separate_new_authorizations_use_distinct_surface_stable_ids() { } #[tokio::test] -async fn repeated_reauthorization_derives_the_same_surface_id_for_one_account() { +async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() { let store = Arc::new(MemoryAccountStore::default()); store .seed_oauth_credential(ImportCodexOAuthCredential { @@ -416,6 +418,15 @@ async fn repeated_reauthorization_derives_the_same_surface_id_for_one_account() }) .await; let account_id = ProviderAccountId::new("acct_surface_reauth").expect("account id"); + store + .update_account(ProviderAccountUpdate { + account_id: account_id.clone(), + name: String::new(), + email: Some("reauthorize@example.com".to_owned()), + plan_type: None, + }) + .await + .unwrap(); let mut surface_ids = Vec::new(); for request_id in ["surface-reauth-first", "surface-reauth-second"] { let service = CodexOAuthAdminService::new( diff --git a/docs/api.md b/docs/api.md index ed8543d50..1df56291a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -961,7 +961,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `POST` | `/api/admin/accounts/import-tasks/stop` | `{ taskId }` | 跳过未开始的条目,已开始的条目继续完成 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 停用账号只启用调度;已启用账号强制清除本地错误/额度/cooldown 事实,不访问上游 | -| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl?, connection? }` | 一次更新账号设置;`connection` 支持 OpenAI OAuth 传输方式及 API Key 连接配置,见下文 | +| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, name?, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl?, connection? }` | 一次更新账号设置;`connection` 支持 OpenAI OAuth 传输方式及 API Key 连接配置,见下文 | | `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游;Provider 未提供额度能力时返回空额度投影 | @@ -1023,6 +1023,10 @@ OpenAI 点数随现有额度刷新和正常请求的额度信息同步,不与 真实额度拒绝后的后台回读同样支持此恢复路径,但不会自动重发本次失败请求;点数不能清除 凭据错误、停用或冷却状态。上游点数信息也可能延迟,后续真实拒绝仍会记录额度耗尽 +账号名称是本地显示信息,与账号 ID、上游邮箱和凭据独立。编辑接口的可选 `name` 省略或为 `null` 时保留原值; +提供字符串时去除首尾空白,空字符串清除名称并回退显示邮箱,原始输入最多 512 个 UTF-8 字节且不能包含控制字符。 +改名保留账号配置和用量记录,凭据刷新与重新授权保留名称;列表搜索匹配当前名称和邮箱 + 账号列表和详情返回 `notes`(无备注时为 `null`)。编辑时省略或 `null` 保留原备注;字符串最多 500 个 Unicode 字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、 重新授权、凭据刷新及批量调度更新均保留已有备注 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 4007ffa83..7de039300 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -448,6 +448,7 @@ interface AccountUpdateParam { outboundProxyUrl?: string outboundProxyId?: string accountId: string + name?: string notes?: string enabled: boolean concurrencyLimit: number | null diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 4f7e8de68..03d1cfae6 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -159,6 +159,8 @@ "elapsed": "Elapsed" }, "editor": { + "namePlaceholder": "Leave blank to use the account email", + "nameInvalid": "Use an account name within 512 bytes, without line breaks or control characters", "title": "Edit account", "transportLabel": "Upstream transport", "loadError": "Could not load upstream settings", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index 3b97f32c5..4744bfbc8 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -159,6 +159,8 @@ "elapsed": "占用时长" }, "editor": { + "namePlaceholder": "留空默认使用账号邮箱", + "nameInvalid": "账号名称最多 512 字节,不能包含换行或控制字符", "title": "编辑账号", "transportLabel": "上游传输方式", "loadError": "上游设置读取失败", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 69eb8a656..ae73cd2f2 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -159,6 +159,8 @@ "elapsed": "佔用時長" }, "editor": { + "namePlaceholder": "留空預設使用賬號郵箱", + "nameInvalid": "賬號名稱最多 512 位元組,不能包含換行或控制字元", "title": "編輯賬號", "transportLabel": "上游傳輸方式", "loadError": "上游設置讀取失敗", diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index ddd25c2a5..18929bca4 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -3,7 +3,7 @@ import type { ApiKeyAccountForm } from '../utils/upstreamApiKey' import type { Account, AccountGroupRef, AccountModelAccess } from '@/api' -import { ZButton, ZDialog, ZFormItem, ZSegmented, ZTextarea } from '@codex-proxy/ui' +import { ZButton, ZDialog, ZFormItem, ZInput, ZSegmented, ZTextarea } from '@codex-proxy/ui' import { useI18n } from 'vue-i18n' import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue' @@ -36,6 +36,7 @@ const transportOptions = [ { label: 'SSE', value: 'http' }, ] const notes = defineModel('notes', { required: true }) +const name = defineModel('name', { required: true }) const enabled = defineModel('enabled', { required: true }) const concurrencyLimit = defineModel('concurrencyLimit', { required: true }) const modelAccess = defineModel('modelAccess', { required: true }) @@ -95,6 +96,15 @@ const selectedGroupIds = defineModel('selectedGroupIds', { required: t :account-id="account.id" /> + + + + (), @@ -25,27 +24,21 @@ const props = withDefaults( size: 'md', showPlan: false, showNotes: false, - titleMode: 'local-part', metaPosition: 'title', metaSize: 'sm', }, ) -const emailText = computed(() => accountIdentityLabel(props.account)) +const displayTitle = computed(() => accountIdentityLabel(props.account)) const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined) -const displayTitle = computed(() => - visibleNotes.value || props.titleMode === 'email' || props.account.authenticationKind === 'api_key' - ? emailText.value - : emailText.value.split('@')[0], -) - -const secondaryText = computed(() => - props.titleMode === 'email' || props.account.authenticationKind === 'api_key' - ? null - : emailText.value, -) +const identityTitle = computed(() => { + const email = props.account.email?.trim() + return email && email !== displayTitle.value + ? `${displayTitle.value}\n${email}` + : displayTitle.value +}) const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase()) @@ -55,12 +48,6 @@ const avatarSizeClass = computed(() => : 'size-9 text-cp', ) -const secondaryClass = computed(() => - props.size === 'lg' - ? 'mt-1 text-cp-sm text-cp-text-secondary' - : 'mt-0.5 font-mono text-cp-xs text-cp-text-quaternary', -) - const metaGapClass = computed(() => props.metaSize === 'xs' ? 'gap-1' : 'gap-1.5') @@ -81,7 +68,7 @@ const avatarToneClass = computed(() => {
- + {{ displayTitle }} {
-
- {{ secondaryText }} -
diff --git a/frontend/src/views/accounts/components/account-quota-forecast-modal/index.vue b/frontend/src/views/accounts/components/account-quota-forecast-modal/index.vue index dd591762f..346cee97d 100644 --- a/frontend/src/views/accounts/components/account-quota-forecast-modal/index.vue +++ b/frontend/src/views/accounts/components/account-quota-forecast-modal/index.vue @@ -89,7 +89,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
- + diff --git a/frontend/src/views/accounts/composables/useAccountEditor.ts b/frontend/src/views/accounts/composables/useAccountEditor.ts index 9ee7080e8..927628e1c 100644 --- a/frontend/src/views/accounts/composables/useAccountEditor.ts +++ b/frontend/src/views/accounts/composables/useAccountEditor.ts @@ -17,6 +17,7 @@ export function useAccountEditor(options: { const showEditModal = shallowRef(false) // 保存后列表可能因筛选移除该账号,编辑窗口的退场仍需保留原账号内容。 const editingAccount = shallowRef(null) + const name = shallowRef('') const notes = shallowRef('') const schedulingEnabled = shallowRef(true) const concurrencyLimit = shallowRef('') @@ -70,6 +71,7 @@ export function useAccountEditor(options: { function open(account: Account) { clearCredentials() editingAccount.value = account + name.value = account.name notes.value = account.notes ?? '' proxyMode.value = 'preserve' proxyId.value = '' @@ -90,6 +92,12 @@ export function useAccountEditor(options: { const accountId = editingAccount.value?.id if (!accountId || saving.value) return + if (new TextEncoder().encode(name.value).length > 512 || /\p{Cc}/u.test(name.value)) { + ZNotification.warning({ + message: t('accounts.editor.nameInvalid'), + }) + return + } const isApiKey = isOpenAiApiKeyAccount(editingAccount.value) const isOAuth = isOpenAiOAuthAccount(editingAccount.value) const configuration = isApiKey && configurationReady.value @@ -124,6 +132,9 @@ export function useAccountEditor(options: { await saveAction.run(async () => { const settings = { accountId, + name: name.value.trim() === editingAccount.value?.name + ? undefined + : name.value.trim(), notes: notes.value, outboundProxyId: proxyMode.value === 'preserve' ? undefined @@ -188,6 +199,7 @@ export function useAccountEditor(options: { configurationReady, showEditModal, editingAccount, + name, notes, schedulingEnabled, concurrencyLimit, diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index e651316e8..0d16c6a8d 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -192,6 +192,7 @@ const { configurationReady, showEditModal, editingAccount, + name: editingName, notes: editingNotes, schedulingEnabled, concurrencyLimit: editingConcurrencyLimit, @@ -440,6 +441,7 @@ const { v-model="showEditModal" v-model:api-key="editingApiKey" v-model:oauth-transport="editingOAuthTransport" + v-model:name="editingName" v-model:notes="editingNotes" v-model:enabled="schedulingEnabled" v-model:concurrency-limit="editingConcurrencyLimit" diff --git a/frontend/src/views/accounts/utils/accountIdentity.ts b/frontend/src/views/accounts/utils/accountIdentity.ts index 775d4d735..1e9a9df9d 100644 --- a/frontend/src/views/accounts/utils/accountIdentity.ts +++ b/frontend/src/views/accounts/utils/accountIdentity.ts @@ -4,8 +4,9 @@ type AccountIdentity = Pick & Partial> export function accountIdentityLabel(account: AccountIdentity): string { - if (account.authenticationKind === 'api_key' && account.name) - return account.name + const name = account.name?.trim() + if (name) + return name const email = account.email?.trim() if (email) return email