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