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:
think-back
2026-10-08 18:20:24 +08:00
committed by GitHub
co-authored by Codex
parent faf50c28ad
commit 9483f2c91b
21 changed files with 475 additions and 43 deletions
@@ -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");
+46 -3
View File
@@ -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
View File
@@ -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` 表示没有可用槽位。
+15
View File
@@ -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