mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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 <noreply@openai.com>
This commit is contained in:
@@ -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<AccountGroupRef>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
/// 创建账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
|
||||
@@ -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<AccountGroupPage>;
|
||||
|
||||
async fn list_account_group_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupOptionsPage>;
|
||||
|
||||
async fn load_account_group_members(
|
||||
&self,
|
||||
group_ids: &[gateway_core::routing::AccountGroupId],
|
||||
|
||||
@@ -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<AccountGroupPage, AdminError>;
|
||||
async fn list_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> Result<AccountGroupOptionsPage, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
@@ -130,6 +134,16 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn list_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> Result<AccountGroupOptionsPage, AdminError> {
|
||||
self.store
|
||||
.list_account_group_options(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account group options"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
@@ -144,6 +201,24 @@ impl AccountGroupStore for FakeGroupStore {
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_account_group_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupOptionsPage> {
|
||||
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()
|
||||
|
||||
@@ -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<AccountGroupOptionsPage> {
|
||||
Err(unavailable("account group options"))
|
||||
}
|
||||
|
||||
async fn load_account_group_members(
|
||||
&self,
|
||||
_: &[gateway_core::routing::AccountGroupId],
|
||||
|
||||
@@ -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<AccountGroupRefView>,
|
||||
page: PageMeta,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountGroupOptionsPage> 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::<S>))
|
||||
.route("/api/admin/account-groups/options", get(list_options::<S>))
|
||||
.route("/api/admin/account-groups/create", post(create::<S>))
|
||||
.route("/api/admin/account-groups/update", post(update::<S>))
|
||||
.route("/api/admin/account-groups/enable", post(enable::<S>))
|
||||
@@ -270,6 +304,26 @@ where
|
||||
.route("/api/admin/account-groups/delete", post(delete::<S>))
|
||||
}
|
||||
|
||||
async fn list_options<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AccountGroupOptionsPage> {
|
||||
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::<Vec<_>>();
|
||||
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],
|
||||
|
||||
@@ -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<AccountGroupOptionsPage> {
|
||||
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::<Postgres>::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::<StoreResult<Vec<_>>>()
|
||||
.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<AccountGroupRecord>
|
||||
})
|
||||
}
|
||||
|
||||
fn group_ref(row: &sqlx::postgres::PgRow) -> StoreResult<AccountGroupRef> {
|
||||
Ok(AccountGroupRef {
|
||||
id: AccountGroupId::new(
|
||||
row.try_get::<String, _>("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::<String, _>("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],
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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` 表示没有可用槽位。
|
||||
|
||||
@@ -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<AccountGroupOptionsResponse>({
|
||||
url: '/api/admin/account-groups/options',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function createAccountGroup(data: AccountGroupCreateParam) {
|
||||
return request<AccountGroupMutationResponse>({
|
||||
url: '/api/admin/account-groups/create',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroupRef } from '@/api'
|
||||
|
||||
import { BaseCheckbox } from '@codex-proxy/ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroupRef } from '@/api'
|
||||
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { getAccountGroups } from '@/api'
|
||||
import { getAccountGroupOptions } from '@/api'
|
||||
import { useRequestState } from './useRequestState'
|
||||
|
||||
export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) {
|
||||
const groups = shallowRef<AccountGroup[]>([])
|
||||
const groups = shallowRef<AccountGroupRef[]>([])
|
||||
const request = useRequestState()
|
||||
const { loading } = request
|
||||
|
||||
@@ -13,12 +13,12 @@ export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) {
|
||||
const requestId = request.start()
|
||||
const requestOptions = { signal: request.signal }
|
||||
try {
|
||||
const first = await getAccountGroups({ page: 1, pageSize: 200 }, requestOptions)
|
||||
const first = await getAccountGroupOptions({ page: 1, pageSize: 200 }, requestOptions)
|
||||
if (!request.isCurrent(requestId))
|
||||
return []
|
||||
const items = [...first.items]
|
||||
for (let page = 2; page <= first.page.totalPages; page += 1) {
|
||||
const result = await getAccountGroups({ page, pageSize: first.page.pageSize }, requestOptions)
|
||||
const result = await getAccountGroupOptions({ page, pageSize: first.page.pageSize }, requestOptions)
|
||||
if (!request.isCurrent(requestId))
|
||||
return []
|
||||
items.push(...result.items)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup, AccountModelAccess } from '@/api'
|
||||
import type { AccountGroupRef, AccountModelAccess } from '@/api'
|
||||
|
||||
import { BaseButton, BaseModal } from '@codex-proxy/ui'
|
||||
import AccountSettingsFields from './AccountSettingsFields.vue'
|
||||
@@ -7,7 +7,7 @@ import AccountSettingsFields from './AccountSettingsFields.vue'
|
||||
defineProps<{
|
||||
selectedCount: number
|
||||
catalogAccountId?: string
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
saving: boolean
|
||||
hasChanges: boolean
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
|
||||
|
||||
import type { Account, AccountGroup, AccountModelAccess } from '@/api'
|
||||
import type { Account, AccountGroupRef, AccountModelAccess } from '@/api'
|
||||
|
||||
import { BaseButton, BaseFormItem, BaseModal, BaseSegmented, BaseTextarea } from '@codex-proxy/ui'
|
||||
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
|
||||
@@ -13,7 +13,7 @@ import AccountSettingsFields from './AccountSettingsFields.vue'
|
||||
|
||||
defineProps<{
|
||||
account: Account | null
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
saving: boolean
|
||||
configurationLoading: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroupRef } from '@/api'
|
||||
import { BaseButton, BaseInput, BaseSelect } from '@codex-proxy/ui'
|
||||
|
||||
import { Download, ListTodo, Pencil, Search, Trash2, Upload } from '@lucide/vue'
|
||||
@@ -14,7 +14,7 @@ const props = defineProps<{
|
||||
batchDeleting: boolean
|
||||
exportingAccounts: boolean
|
||||
exportDisabledReason: string
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup, AccountModelAccess } from '@/api'
|
||||
import type { AccountGroupRef, AccountModelAccess } from '@/api'
|
||||
import { BaseFormItem, BaseInput, BaseSwitch } from '@codex-proxy/ui'
|
||||
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import AccountModelAccessField from './AccountModelAccessField.vue'
|
||||
import AccountProxyField from './AccountProxyField.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
disabled: boolean
|
||||
endpoint?: string | null
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountCreateForm } from '../../utils/accountCreate'
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroupRef } from '@/api'
|
||||
import { BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
|
||||
import AccountSettingsFields from '../AccountSettingsFields.vue'
|
||||
import AccountProviderChooser from './AccountProviderChooser.vue'
|
||||
|
||||
defineProps<{
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
disabled: boolean
|
||||
proxyError?: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AccountAuthorizationView } from '../../composables/useAccountAuthorization'
|
||||
import type { AccountCreateForm, AccountImportMode } from '../../utils/accountCreate'
|
||||
|
||||
import type { Account, AccountGroup } from '@/api'
|
||||
import type { Account, AccountGroupRef } from '@/api'
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { BaseButton, BaseIconButton, BaseModal, BaseSegmented } from '@codex-proxy/ui'
|
||||
import { Copy, LayoutGrid, Settings2 } from '@lucide/vue'
|
||||
@@ -21,7 +21,7 @@ import AccountSetupFields from './AccountSetupFields.vue'
|
||||
import { resolveAccountCreatePresentation } from './presenter'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupsLoading: boolean
|
||||
saving?: boolean
|
||||
oauthLoading?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiKeyFormValue } from '../composables/useApiKeyEditor'
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroupRef } from '@/api'
|
||||
import { BaseButton, BaseForm, BaseFormItem, BaseInput, BaseModal } from '@codex-proxy/ui'
|
||||
|
||||
import { DollarSign, KeyRound } from '@lucide/vue'
|
||||
@@ -9,7 +9,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import ProviderRequestProfilesEditor from '@/components/client-profile/ProviderRequestProfilesEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
groups: AccountGroup[]
|
||||
groups: AccountGroupRef[]
|
||||
groupLoading: boolean
|
||||
editing: boolean
|
||||
saving: boolean
|
||||
|
||||
Reference in New Issue
Block a user