From 9483f2c91ba22ba0989cd6c8f8b9498c962bbe6e Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:20:24 +0800 Subject: [PATCH] perf(groups): avoid aggregate queries when loading group selectors Load account and API key group selectors through a lightweight admin options endpoint while preserving pagination, filters, disabled groups and revision metadata. Keep aggregate statistics on the group management endpoint. Co-authored-by: Codex --- .../gateway-admin/src/model/account_groups.rs | 10 +++ .../crates/gateway-admin/src/ports/store.rs | 10 ++- .../src/use_case/account_groups.rs | 20 ++++- .../tests/use_case/account_groups.rs | 85 +++++++++++++++++-- .../gateway-admin/tests/use_case/mod.rs | 12 ++- .../gateway-api/src/admin/account_groups.rs | 58 ++++++++++++- .../gateway-api/tests/admin/account_groups.rs | 63 ++++++++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 49 ++++++++++- .../src/postgres/account_groups.rs | 67 ++++++++++++++- .../tests/postgres/account_groups.rs | 84 +++++++++++++++++- docs/api.md | 3 +- frontend/src/api/modules/groups.ts | 15 ++++ .../components/AccountGroupCheckboxGrid.vue | 4 +- .../src/composables/useAccountGroupCatalog.ts | 10 +-- .../components/AccountBatchEditModal.vue | 4 +- .../accounts/components/AccountEditModal.vue | 4 +- .../accounts/components/AccountFilters.vue | 4 +- .../components/AccountSettingsFields.vue | 4 +- .../AccountSetupFields.vue | 4 +- .../components/account-create-modal/index.vue | 4 +- .../views/keys/components/ApiKeyFormModal.vue | 4 +- 21 files changed, 475 insertions(+), 43 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/account_groups.rs b/backend/crates/gateway-admin/src/model/account_groups.rs index 354802afe..085df9fc1 100644 --- a/backend/crates/gateway-admin/src/model/account_groups.rs +++ b/backend/crates/gateway-admin/src/model/account_groups.rs @@ -126,6 +126,16 @@ pub struct AccountGroupPage { pub page_size: u16, } +/// 分组选择器使用的轻量分页结果 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountGroupOptionsPage { + pub config_revision: Revision, + pub items: Vec, + pub total: u64, + pub page: u32, + pub page_size: u16, +} + /// 创建账号分组 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateAccountGroup { diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 7986e2107..936c6f54c 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -12,8 +12,9 @@ use super::backup::BackupStorePorts; use crate::model::{ MutationContext, Revision, account_groups::{ - AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, - DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, + AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup, + SetAccountGroupEnabled, UpdateAccountGroup, }, accounts::{ AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot, @@ -385,6 +386,11 @@ pub trait AccountGroupStore: Send + Sync { query: AccountGroupListQuery, ) -> AdminStoreResult; + async fn list_account_group_options( + &self, + query: AccountGroupListQuery, + ) -> AdminStoreResult; + async fn load_account_group_members( &self, group_ids: &[gateway_core::routing::AccountGroupId], diff --git a/backend/crates/gateway-admin/src/use_case/account_groups.rs b/backend/crates/gateway-admin/src/use_case/account_groups.rs index d6c8b22ef..30b241df4 100644 --- a/backend/crates/gateway-admin/src/use_case/account_groups.rs +++ b/backend/crates/gateway-admin/src/use_case/account_groups.rs @@ -19,9 +19,9 @@ use crate::{ AdminError, MutationContext, account_groups::{ AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery, - AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord, - CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, - UpdateAccountGroup, + AccountGroupMemberFact, AccountGroupMutation, AccountGroupOptionsPage, + AccountGroupPage, AccountGroupRecord, CreateAccountGroup, DeleteAccountGroup, + NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup, }, }, ports::store::{AccountGroupStore, AccountRuntimeStore}, @@ -33,6 +33,10 @@ use super::{map_store_error, publish_committed}; #[async_trait] pub trait AccountGroupService: Send + Sync { async fn list(&self, query: AccountGroupListQuery) -> Result; + async fn list_options( + &self, + query: AccountGroupListQuery, + ) -> Result; async fn create( &self, context: &MutationContext, @@ -130,6 +134,16 @@ impl AccountGroupService for DefaultAccountGroupService { Ok(page) } + async fn list_options( + &self, + query: AccountGroupListQuery, + ) -> Result { + self.store + .list_account_group_options(query) + .await + .map_err(|error| map_store_error(error, "account group options")) + } + async fn create( &self, context: &MutationContext, diff --git a/backend/crates/gateway-admin/tests/use_case/account_groups.rs b/backend/crates/gateway-admin/tests/use_case/account_groups.rs index cb5298910..8cc1efee2 100644 --- a/backend/crates/gateway-admin/tests/use_case/account_groups.rs +++ b/backend/crates/gateway-admin/tests/use_case/account_groups.rs @@ -10,12 +10,13 @@ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use gateway_admin::{ model::{ - MutationContext, PageSize, Revision, + AdminErrorKind, MutationContext, PageSize, Revision, account_groups::{ AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, - AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, - AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, - SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, + AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, AccountGroupRef, + AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, + UpdateAccountGroup, }, accounts::AccountRuntimeSnapshot, observability::DecimalAmount, @@ -90,6 +91,62 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac ); } +#[tokio::test] +async fn group_options_query_should_skip_member_and_runtime_enrichment() { + let groups = Arc::new(FakeGroupStore::default()); + let runtime = Arc::new(FakeRuntimeStore::default()); + let service = AdminHarness::new() + .account_groups(groups.clone()) + .account_runtime(runtime.clone()) + .build() + .await; + + let page = service + .account_groups() + .list_options(AccountGroupListQuery { + page: 1, + page_size: PageSize::new(20).expect("page size"), + search: None, + enabled: None, + }) + .await + .expect("list account group options"); + + assert_eq!(page.items[0].id.as_str(), GROUP_ID); + assert!( + groups + .requested_groups + .lock() + .expect("requested groups") + .is_empty() + ); + assert!( + runtime + .requested_accounts + .lock() + .expect("requested accounts") + .is_empty() + ); +} + +#[tokio::test] +async fn group_options_query_should_preserve_store_failures() { + let service = AdminHarness::new().build().await; + + let error = service + .account_groups() + .list_options(AccountGroupListQuery { + page: 1, + page_size: PageSize::new(20).expect("page size"), + search: None, + enabled: None, + }) + .await + .expect_err("unavailable options store must fail"); + + assert_eq!(error.kind(), AdminErrorKind::Unavailable); +} + #[derive(Default)] struct FakeGroupStore { requested_groups: Mutex>, @@ -144,6 +201,24 @@ impl AccountGroupStore for FakeGroupStore { }) } + async fn list_account_group_options( + &self, + query: AccountGroupListQuery, + ) -> AdminStoreResult { + Ok(AccountGroupOptionsPage { + config_revision: Revision::new(1).expect("revision"), + items: vec![AccountGroupRef { + id: group_id(), + name: "Primary".to_owned(), + color: AccountGroupColor::parse("#2563EBFF").expect("color"), + enabled: true, + }], + total: 1, + page: query.page, + page_size: query.page_size.get(), + }) + } + async fn load_account_group_members( &self, group_ids: &[AccountGroupId], @@ -300,7 +375,7 @@ fn unused() -> AdminStoreError { #[tokio::test] async fn invalid_group_fields_are_rejected_before_store_mutations() { - use gateway_admin::model::{AdminErrorKind, MutationActor, account_groups::CreateAccountGroup}; + use gateway_admin::model::{MutationActor, account_groups::CreateAccountGroup}; let service = AdminHarness::new() .account_groups(Arc::new(FakeGroupStore::default())) .build() diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 12a9a7c25..129316a8d 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -29,8 +29,9 @@ use gateway_admin::{ model::{ MutationContext, Revision, account_groups::{ - AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, - DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, + AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup, + SetAccountGroupEnabled, UpdateAccountGroup, }, accounts::{ AccountListQuery, AccountPage, AccountRuntimeSnapshot, AccountUpdateResult, @@ -424,6 +425,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore { Err(unavailable("account groups")) } + async fn list_account_group_options( + &self, + _: AccountGroupListQuery, + ) -> AdminStoreResult { + Err(unavailable("account group options")) + } + async fn load_account_group_members( &self, _: &[gateway_core::routing::AccountGroupId], diff --git a/backend/crates/gateway-api/src/admin/account_groups.rs b/backend/crates/gateway-api/src/admin/account_groups.rs index 8c60b4f21..e45ee7053 100644 --- a/backend/crates/gateway-api/src/admin/account_groups.rs +++ b/backend/crates/gateway-api/src/admin/account_groups.rs @@ -16,8 +16,9 @@ use gateway_admin::model::{ PageSize, account_groups::{ AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, AccountGroupListQuery, - AccountGroupMutation, AccountGroupPage, AccountGroupRecord, AccountGroupUsage, - CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupMutation, AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, + AccountGroupUsage, CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled, + UpdateAccountGroup, }, }; use gateway_core::{account::FastMode, routing::AccountGroupId}; @@ -212,6 +213,38 @@ struct AccountGroupPageData { config_revision: u64, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountGroupOptionsPageData { + items: Vec, + page: PageMeta, + config_revision: u64, +} + +impl From for AccountGroupOptionsPageData { + fn from(page: AccountGroupOptionsPage) -> Self { + let total_pages = if page.total == 0 { + 0 + } else { + page.total.div_ceil(u64::from(page.page_size)) + }; + Self { + items: page + .items + .into_iter() + .map(AccountGroupRefView::from) + .collect(), + page: PageMeta::new( + page.page, + u32::from(page.page_size), + page.total, + u32::try_from(total_pages).unwrap_or(u32::MAX), + ), + config_revision: page.config_revision.get(), + } + } +} + impl From<(AccountGroupPage, crate::time::TimePresenter)> for AccountGroupPageData { fn from((page, time): (AccountGroupPage, crate::time::TimePresenter)) -> Self { let total_pages = if page.total == 0 { @@ -263,6 +296,7 @@ where { Router::new() .route("/api/admin/account-groups", get(list::)) + .route("/api/admin/account-groups/options", get(list_options::)) .route("/api/admin/account-groups/create", post(create::)) .route("/api/admin/account-groups/update", post(update::)) .route("/api/admin/account-groups/enable", post(enable::)) @@ -270,6 +304,26 @@ where .route("/api/admin/account-groups/delete", post(delete::)) } +async fn list_options( + _auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +where + S: SessionState + Send + Sync, +{ + let result = state + .admin_services() + .account_groups() + .list_options(query.into_command().map_err(map_wire_error)?) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AccountGroupOptionsPageData::from(result)), + )) +} + async fn list( _auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/tests/admin/account_groups.rs b/backend/crates/gateway-api/tests/admin/account_groups.rs index f50c2c2ce..292fc5e18 100644 --- a/backend/crates/gateway-api/tests/admin/account_groups.rs +++ b/backend/crates/gateway-api/tests/admin/account_groups.rs @@ -49,6 +49,36 @@ async fn list_route_should_keep_camel_case_group_and_page_wire() { assert!(value["data"]["items"][0].get("member_count").is_none()); } +#[tokio::test] +async fn options_route_should_return_only_lightweight_group_fields() { + let fixture = authenticated_fixture().await; + let response = request( + router(&fixture), + Method::GET, + "/api/admin/account-groups/options?page=1&pageSize=1&search=alpha&enabled=true", + None, + true, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let value = response_json(response).await; + assert_eq!( + value["data"]["items"][0], + json!({ + "id": PRIMARY_GROUP_ID, + "name": "Alpha routing", + "color": "#2563EBFF", + "enabled": true + }) + ); + assert_eq!(value["data"]["page"]["page"], 1); + assert_eq!(value["data"]["page"]["pageSize"], 1); + assert_eq!(value["data"]["page"]["total"], 1); + assert_eq!(value["data"]["page"]["totalPages"], 1); + assert_eq!(value["data"]["configRevision"], 7); +} + #[tokio::test] async fn create_route_should_create_an_empty_group() { let fixture = authenticated_fixture().await; @@ -163,6 +193,11 @@ async fn list_route_should_reject_unknown_and_invalid_pagination_fields() { "/api/admin/account-groups?page=0", "/api/admin/account-groups?pageSize=0", "/api/admin/account-groups?pageSize=201", + "/api/admin/account-groups/options?other=true", + "/api/admin/account-groups/options?page=0", + "/api/admin/account-groups/options?pageSize=0", + "/api/admin/account-groups/options?pageSize=201", + "/api/admin/account-groups/options?search=%00", ] { let response = request(router(&fixture), Method::GET, uri, None, true).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); @@ -219,6 +254,14 @@ async fn account_group_routes_should_require_admin_authentication() { false, ) .await; + let options_response = request( + router(&fixture), + Method::GET, + "/api/admin/account-groups/options", + None, + false, + ) + .await; let post_response = request( router(&fixture), Method::POST, @@ -229,9 +272,29 @@ async fn account_group_routes_should_require_admin_authentication() { .await; assert_eq!(get_response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(options_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(post_response.status(), StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn options_route_should_reject_client_key_identity() { + let fixture = crate::support::key_fixture().await; + let response = router(&fixture) + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/admin/account-groups/options") + .header("x-api-key", crate::support::RAW_KEY) + .header("x-request-id", "req_account_group_options_client_key") + .body(Body::empty()) + .expect("client key options request"), + ) + .await + .expect("client key options response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + async fn authenticated_fixture() -> AdminTestFixture { let fixture = AdminTestFixture::new().await; fixture.auth.insert_session("valid-session"); diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 9b80ed243..e451ecaae 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -18,9 +18,10 @@ use gateway_admin::{ MutationContext, Revision, account_groups::{ AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, - AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, - AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, - SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, + AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, AccountGroupRef, + AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, + UpdateAccountGroup, }, accounts::{ AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot, @@ -789,6 +790,48 @@ impl AccountGroupStore for MemoryAccountGroupStore { }) } + async fn list_account_group_options( + &self, + query: AccountGroupListQuery, + ) -> AdminStoreResult { + let state = self.state.lock().expect("account groups"); + let search = query.search.as_ref().map(|value| value.to_lowercase()); + let mut matching = state + .groups + .values() + .filter(|record| { + query + .enabled + .is_none_or(|enabled| record.enabled == enabled) + }) + .filter(|record| { + search + .as_ref() + .is_none_or(|search| record.name.to_lowercase().contains(search)) + }) + .map(|record| AccountGroupRef { + id: record.id.clone(), + name: record.name.clone(), + color: record.color.clone(), + enabled: record.enabled, + }) + .collect::>(); + matching.sort_by(|left, right| left.name.cmp(&right.name)); + let total = matching.len() as u64; + let page_size = usize::from(query.page_size.get()); + let offset = usize::try_from(query.page.saturating_sub(1)) + .unwrap_or(usize::MAX) + .saturating_mul(page_size); + let items = matching.into_iter().skip(offset).take(page_size).collect(); + Ok(AccountGroupOptionsPage { + config_revision: state.revision, + items, + total, + page: query.page, + page_size: query.page_size.get(), + }) + } + async fn load_account_group_members( &self, group_ids: &[gateway_core::routing::AccountGroupId], diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 808cc9aa6..c48cab3ab 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -10,9 +10,10 @@ use gateway_admin::{ MutationContext, account_groups::{ AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, - AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, - AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, - SetAccountGroupEnabled, UpdateAccountGroup, + AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, + AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, AccountGroupRef, + AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, + UpdateAccountGroup, }, observability::DecimalAmount, }, @@ -168,6 +169,44 @@ impl AccountGroupStore for PgAccountGroupRepository { }) } + async fn list_account_group_options( + &self, + query: AccountGroupListQuery, + ) -> AdminStoreResult { + validate_page_query(&query)?; + let total = count_groups(&self.pool, &query) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + let offset = u64::from(query.page.saturating_sub(1)) * u64::from(query.page_size.get()); + let mut statement = QueryBuilder::::new( + "select g.id, g.name, g.color, g.enabled from account_groups g where true", + ); + push_group_filter(&mut statement, &query); + statement.push(" order by g.created_at desc, g.id desc limit "); + statement.push_bind(i64::from(query.page_size.get())); + statement.push(" offset "); + statement.push_bind(i64::try_from(offset).map_err(|_| invalid_admin("page is too large"))?); + let rows = statement + .build() + .fetch_all(&self.pool) + .await + .map_err(|source| { + admin_store_error(ENTITY, unavailable("list account group options", source)) + })?; + let items = rows + .iter() + .map(group_ref) + .collect::>>() + .map_err(|error| admin_store_error(ENTITY, error))?; + Ok(AccountGroupOptionsPage { + config_revision: self.current_revision().await?, + items, + total, + page: query.page, + page_size: query.page_size.get(), + }) + } + async fn load_account_group_members( &self, group_ids: &[AccountGroupId], @@ -538,6 +577,28 @@ fn group_record(row: &sqlx::postgres::PgRow) -> StoreResult }) } +fn group_ref(row: &sqlx::postgres::PgRow) -> StoreResult { + Ok(AccountGroupRef { + id: AccountGroupId::new( + row.try_get::("id") + .map_err(|source| invalid("invalid id").with_source(source))?, + ) + .map_err(|source| invalid("invalid id").with_source(source))?, + name: row + .try_get("name") + .map_err(|source| invalid("invalid name").with_source(source))?, + color: AccountGroupColor::parse( + row.try_get::("color") + .map_err(|source| invalid("invalid color").with_source(source))? + .as_str(), + ) + .ok_or_else(|| invalid("invalid color"))?, + enabled: row + .try_get("enabled") + .map_err(|source| invalid("invalid enabled").with_source(source))?, + }) +} + async fn group_costs( pool: &PgPool, group_ids: &[String], diff --git a/backend/crates/gateway-store/tests/postgres/account_groups.rs b/backend/crates/gateway-store/tests/postgres/account_groups.rs index 59343dad3..e5ff13725 100644 --- a/backend/crates/gateway-store/tests/postgres/account_groups.rs +++ b/backend/crates/gateway-store/tests/postgres/account_groups.rs @@ -1,6 +1,6 @@ //! 验证账号分组聚合、Key 绑定与分组策略的持久化 -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::Duration}; use gateway_admin::{ model::{ @@ -288,6 +288,88 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip database.close().await; } +#[tokio::test] +async fn group_options_should_avoid_aggregate_tables_and_page_filtered_empty_groups() { + let Some(database) = TestDatabase::create("account_group_options").await else { + return; + }; + for (id, name, enabled, created_at) in [ + (MIXED_GROUP, "Production Pool", true, "2026-01-01T00:00:00Z"), + (EMPTY_GROUP, "Disabled Pool", false, "2026-01-02T00:00:00Z"), + ( + "grp_00000000000000000000000000000003", + "Unrelated", + true, + "2026-01-03T00:00:00Z", + ), + ] { + sqlx::query( + "insert into account_groups (id, name, color, enabled, created_at, updated_at) + values ($1, $2, '#2563EBFF', $3, $4::text::timestamptz, $4::text::timestamptz)", + ) + .bind(id) + .bind(name) + .bind(enabled) + .bind(created_at) + .execute(&database.pool) + .await + .expect("seed account group option"); + } + let groups = PgAccountGroupRepository::new(database.pool.clone()); + + let mut locked_tables = database.pool.begin().await.expect("begin table lock"); + sqlx::query( + "lock table account_group_accounts, client_api_key_groups, model_requests + in access exclusive mode", + ) + .execute(&mut *locked_tables) + .await + .expect("lock aggregate source tables"); + + let first = tokio::time::timeout( + Duration::from_secs(1), + groups.list_account_group_options(AccountGroupListQuery { + page: 1, + page_size: PageSize::new(1).expect("page size"), + search: Some("pool".to_owned()), + enabled: None, + }), + ) + .await + .expect("options query must not wait for aggregate source tables") + .expect("list first account group option page"); + assert_eq!(first.total, 2); + assert_eq!(first.items[0].id.as_str(), EMPTY_GROUP); + assert!(!first.items[0].enabled); + + let second = groups + .list_account_group_options(AccountGroupListQuery { + page: 2, + page_size: PageSize::new(1).expect("page size"), + search: Some("POOL".to_owned()), + enabled: None, + }) + .await + .expect("list second account group option page"); + assert_eq!(second.items[0].id.as_str(), MIXED_GROUP); + + let disabled = groups + .list_account_group_options(AccountGroupListQuery { + page: 1, + page_size: PageSize::new(20).expect("page size"), + search: None, + enabled: Some(false), + }) + .await + .expect("list disabled account group options"); + assert_eq!(disabled.total, 1); + assert_eq!(disabled.items[0].id.as_str(), EMPTY_GROUP); + + locked_tables.rollback().await.expect("release table locks"); + + database.close().await; +} + #[tokio::test] async fn group_costs_should_include_statusless_websocket_but_reject_statusless_http() { let Some(database) = TestDatabase::create("account_group_statusless_websocket_cost").await diff --git a/docs/api.md b/docs/api.md index 296c3a58d..42925e922 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1181,13 +1181,14 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | | `GET` | `/api/admin/account-groups` | `page`、`pageSize`、`search`、`enabled` | 分页查询分组;返回账号可用性、并发槽位(Redis 不可用时 `usedSlots=null`)及成功请求 USD 用量 | +| `GET` | `/api/admin/account-groups/options` | `page`、`pageSize`、`search`、`enabled` | 分页查询分组选择项;item 仅返回 `id`、`name`、`color`、`enabled` | | `POST` | `/api/admin/account-groups/create` | `{ name, description, color, fastMode? }` | 创建空分组;`color` 严格为 `#RRGGBBAA`,返回时统一大写 | | `POST` | `/api/admin/account-groups/update` | `{ id, name, description, color, fastMode? }` | 更新名称、描述、颜色和 Fast 模式 | | `POST` | `/api/admin/account-groups/enable` | `{ id }` | 启用 | | `POST` | `/api/admin/account-groups/disable` | `{ id }` | 禁用;已绑定 Key 保持受限,不回退到全部账号 | | `POST` | `/api/admin/account-groups/delete` | `{ id }` | 删除未被 Client Key 引用的组 | -列表数据为 `{ items, page, configRevision }`,其中 item 返回 `memberCount`、按 Provider 聚合的 +两种列表数据均为 `{ items, page, configRevision }`。完整列表 item 返回 `memberCount`、按 Provider 聚合的 `providerCounts` 和 `clientKeyCount`。查询分组成员使用账号列表的 `groupId` 筛选, 不提供独立的分组成员路由;账号的 Provider 不代表整个分组的 Provider。 `capacity.totalSlots` 为 `number | null`:`null` 表示可用成员中存在继承无限并发的账号,`0` 表示没有可用槽位。 diff --git a/frontend/src/api/modules/groups.ts b/frontend/src/api/modules/groups.ts index b8ea37fa1..b983a7e09 100644 --- a/frontend/src/api/modules/groups.ts +++ b/frontend/src/api/modules/groups.ts @@ -54,6 +54,12 @@ export interface AccountGroupListResponse { configRevision: number } +export interface AccountGroupOptionsResponse { + items: AccountGroupRef[] + page: AccountGroupPageMeta + configRevision: number +} + export interface AccountGroupMutationResponse { id: string record: AccountGroup | null @@ -95,6 +101,15 @@ export function getAccountGroups(data: AccountGroupListParams, options: RequestO }) } +export function getAccountGroupOptions(data: AccountGroupListParams, options: RequestOptions = {}) { + return request({ + url: '/api/admin/account-groups/options', + method: 'GET', + params: data, + ...options, + }) +} + export function createAccountGroup(data: AccountGroupCreateParam) { return request({ url: '/api/admin/account-groups/create', diff --git a/frontend/src/components/AccountGroupCheckboxGrid.vue b/frontend/src/components/AccountGroupCheckboxGrid.vue index 57a9dff02..92912fdd2 100644 --- a/frontend/src/components/AccountGroupCheckboxGrid.vue +++ b/frontend/src/components/AccountGroupCheckboxGrid.vue @@ -1,11 +1,11 @@