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 <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-31 21:19:07 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 839054cdaf
commit 7cf99769b1
23 changed files with 369 additions and 43 deletions
@@ -226,6 +226,8 @@ pub struct AccountCapacity {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留名称,空字符串清除名称并使用邮箱展示
pub name: Option<String>,
/// 缺省保留备注;空字符串清空备注
pub notes: Option<String>,
pub enabled: bool,
@@ -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,
@@ -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<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub name: Option<String>,
pub notes: Option<String>,
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,
@@ -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::<UpdateAccountRequest>(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");
@@ -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,
@@ -266,7 +266,6 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
async fn update_provider_account(&self, account: UpdateProviderAccount) -> StoreResult<bool> {
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<ProviderAccountAdminRotation> {
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 {
@@ -252,6 +252,7 @@ impl fmt::Debug for RotateProviderAccount {
pub struct BatchUpdateProviderAccountsAdmin {
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
pub account_ids: Vec<String>,
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
@@ -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
@@ -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<String> = 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(),
@@ -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(),
@@ -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<ProviderAccountId>,
installation_id: String,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
if !valid_text(&name) {
if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) {
return Err(CodexOAuthAdminError::InvalidInput);
}
let expires_at = Utc::now()
@@ -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(
+5 -1
View File
@@ -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
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
重新授权、凭据刷新及批量调度更新均保留已有备注
+1
View File
@@ -448,6 +448,7 @@ interface AccountUpdateParam {
outboundProxyUrl?: string
outboundProxyId?: string
accountId: string
name?: string
notes?: string
enabled: boolean
concurrencyLimit: number | null
@@ -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",
@@ -159,6 +159,8 @@
"elapsed": "占用时长"
},
"editor": {
"namePlaceholder": "留空默认使用账号邮箱",
"nameInvalid": "账号名称最多 512 字节,不能包含换行或控制字符",
"title": "编辑账号",
"transportLabel": "上游传输方式",
"loadError": "上游设置读取失败",
@@ -159,6 +159,8 @@
"elapsed": "佔用時長"
},
"editor": {
"namePlaceholder": "留空預設使用賬號郵箱",
"nameInvalid": "賬號名稱最多 512 位元組,不能包含換行或控制字元",
"title": "編輯賬號",
"transportLabel": "上游傳輸方式",
"loadError": "上游設置讀取失敗",
@@ -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<string>('notes', { required: true })
const name = defineModel<string>('name', { required: true })
const enabled = defineModel<boolean>('enabled', { required: true })
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
const modelAccess = defineModel<AccountModelAccess | undefined>('modelAccess', { required: true })
@@ -95,6 +96,15 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:account-id="account.id"
/>
<ZFormItem :label="t('accounts.credentials.accountName')">
<ZInput
v-model="name"
:aria-label="t('accounts.credentials.accountName')"
:placeholder="t('accounts.editor.namePlaceholder')"
:disabled="saving"
/>
</ZFormItem>
<ZFormItem :label="t('common.fields.notes')">
<ZTextarea
v-model="notes"
@@ -17,7 +17,6 @@ const props = withDefaults(
size?: 'md' | 'lg'
showPlan?: boolean
showNotes?: boolean
titleMode?: 'local-part' | 'email'
metaPosition?: 'title' | 'secondary'
metaSize?: 'xs' | 'sm'
}>(),
@@ -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(() => {
</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate text-cp font-heavy text-cp-text" :title="displayTitle">
<span class="min-w-0 truncate text-cp font-heavy text-cp-text" :title="identityTitle">
{{ displayTitle }}
</span>
<span
@@ -102,9 +89,6 @@ const avatarToneClass = computed(() => {
<AccountPlanBadge v-if="showPlan" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</div>
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-0.5" />
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
{{ secondaryText }}
</div>
</div>
</div>
</template>
@@ -89,7 +89,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<AccountIdentityCell :account="account" show-plan title-mode="email" meta-position="secondary" meta-size="xs" class="max-w-full">
<AccountIdentityCell :account="account" show-plan meta-position="secondary" meta-size="xs" class="max-w-full">
<template #meta>
<ProviderIconGroup :provider="account.provider" size="xs" />
</template>
@@ -17,6 +17,7 @@ export function useAccountEditor(options: {
const showEditModal = shallowRef(false)
// 保存后列表可能因筛选移除该账号,编辑窗口的退场仍需保留原账号内容。
const editingAccount = shallowRef<Account | null>(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,
+2
View File
@@ -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"
@@ -4,8 +4,9 @@ type AccountIdentity = Pick<Account, 'id' | 'email'>
& Partial<Pick<Account, 'accountId' | 'name' | 'authenticationKind'>>
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