From a48139b4c2cc2b050129dd66b21e09591d2f8fa6 Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 27 Oct 2026 16:16:37 +0800 Subject: [PATCH] fix(store): support literal substring search in admin lists Match account names and emails, client key names and labels, and observability search fields by literal substrings. Preserve wildcard escaping and existing case rules. Co-authored-by: Codex --- .../gateway-store/src/postgres/client_keys.rs | 6 +- .../crates/gateway-store/src/postgres/mod.rs | 2 +- .../src/postgres/observability/mod.rs | 2 +- .../src/postgres/observability/queries/ops.rs | 8 +- .../postgres/observability/queries/usage.rs | 16 +-- .../provider_accounts/admin_queries.rs | 11 +- .../src/postgres/query_pattern.rs | 9 +- .../gateway-store/tests/postgres/analytics.rs | 6 +- .../tests/postgres/client_keys.rs | 10 +- .../tests/postgres/observability/mod.rs | 104 +++++++++--------- .../provider_accounts/admin_adapter.rs | 54 +++++++++ .../tests/postgres/provider_accounts/mod.rs | 14 ++- docs/api.md | 6 +- 13 files changed, 150 insertions(+), 98 deletions(-) diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 252c254f9..2cd367f41 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -1484,12 +1484,12 @@ async fn count_client_api_keys(pool: &PgPool, search: Option<&str>) -> StoreResu fn push_client_key_search(statement: &mut QueryBuilder, search: Option<&str>) { if let Some(search) = search { - let prefix = crate::postgres::literal_prefix_pattern(search); + let pattern = crate::postgres::literal_contains_pattern(&search.to_lowercase()); statement.push(" and (lower(name) like "); - statement.push_bind(prefix.clone()); + statement.push_bind(pattern.clone()); statement.push(" escape '\\'"); statement.push(" or lower(coalesce(label, '')) like "); - statement.push_bind(prefix); + statement.push_bind(pattern); statement.push(" escape '\\'"); statement.push(")"); } diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index 86481846b..e6d05bc3b 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -43,7 +43,7 @@ pub use ops_events::*; pub use plugins::PgPluginStore; pub use provider_accounts::*; pub use proxies::PgProxyRepository; -use query_pattern::literal_prefix_pattern; +use query_pattern::literal_contains_pattern; pub use retention::*; pub use runtime_settings::*; pub use snapshot::*; diff --git a/backend/crates/gateway-store/src/postgres/observability/mod.rs b/backend/crates/gateway-store/src/postgres/observability/mod.rs index 6d77abba2..26ed34b24 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mod.rs @@ -19,7 +19,7 @@ use crate::{ }; use super::{ - completed_usage_fact_predicate, push_completed_usage_fact_filter, + completed_usage_fact_predicate, literal_contains_pattern, push_completed_usage_fact_filter, push_unrecovered_request_filter, }; diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index 0ee803f5a..19a1c4655 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -285,7 +285,7 @@ fn push_request_error_predicates( statement.push_bind(i32::from(status)); } if let Some(search) = &filter.search { - push_prefix_search( + push_contains_search( statement, &[ "mr.id", @@ -331,7 +331,7 @@ fn push_ops_event_predicates( statement.push_bind(i32::from(status)); } if let Some(search) = &filter.search { - push_prefix_search( + push_contains_search( statement, &[ "oe.id", @@ -375,8 +375,8 @@ fn push_response_id_filter( } } -fn push_prefix_search(statement: &mut QueryBuilder, columns: &[&str], value: &str) { - let pattern = literal_prefix_pattern(value); +fn push_contains_search(statement: &mut QueryBuilder, columns: &[&str], value: &str) { + let pattern = literal_contains_pattern(value); statement.push(" and ("); for (index, column) in columns.iter().enumerate() { if index > 0 { diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index 47c96ea5c..58c97406f 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -100,7 +100,7 @@ pub(crate) fn push_usage_filter( query.push_bind(value.clone()); } if let Some(value) = &filter.search { - let pattern = literal_prefix_pattern(value); + let pattern = literal_contains_pattern(value); query.push(format!(" and ({alias}.requested_model_id like ")); query.push_bind(pattern.clone()); for column in [ @@ -123,20 +123,6 @@ pub(crate) fn push_usage_filter( } } -fn literal_contains_pattern(value: &str) -> String { - format!("%{}", literal_prefix_pattern(value)) -} - -pub(crate) fn literal_prefix_pattern(value: &str) -> String { - format!( - "{}%", - value - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") - ) -} - pub(crate) const USAGE_LIST_RECORD_SELECT: &str = "select mr.id, mr.outcome, mr.provider_error_code as error_code, mr.error_message, mr.client_status_code, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id, mr.provider_kind, mr.provider_account_ref, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 37b0f1994..78593b004 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -23,10 +23,10 @@ pub(crate) async fn load_admin_account_page( .provider_kind .as_ref() .map(|provider| provider.as_str().to_owned()); - let search_prefix = query + let search_pattern = query .search .as_deref() - .map(crate::postgres::literal_prefix_pattern); + .map(|search| crate::postgres::literal_contains_pattern(&search.to_lowercase())); let status = query.status.map(|status| status.as_str().to_owned()); let (group_mode, group_id) = match query.group_filter.as_ref() { None => (0_i16, None), @@ -79,11 +79,8 @@ pub(crate) async fn load_admin_account_page( join provider_accounts a on a.id = status.id where ($3::text is null or a.provider_kind = $3) and ($4::text is null or - lower(a.id) like $4 escape '\\' or lower(a.name) like $4 escape '\\' or - lower(coalesce(a.email, '')) like $4 escape '\\' or - lower(coalesce(a.upstream_account_id, '')) like $4 escape '\\' or - lower(coalesce(a.upstream_user_id, '')) like $4 escape '\\') + lower(coalesce(a.email, '')) like $4 escape '\\') and ($5::text is null or status.admin_status = $5) and ($6::smallint = 0 or ($6 = 1 and exists ( @@ -157,7 +154,7 @@ pub(crate) async fn load_admin_account_page( .bind(active_rate_limited_ids) .bind(now) .bind(provider_kind) - .bind(search_prefix) + .bind(search_pattern) .bind(status) .bind(group_mode) .bind(group_id) diff --git a/backend/crates/gateway-store/src/postgres/query_pattern.rs b/backend/crates/gateway-store/src/postgres/query_pattern.rs index e44a664be..7c17bd53f 100644 --- a/backend/crates/gateway-store/src/postgres/query_pattern.rs +++ b/backend/crates/gateway-store/src/postgres/query_pattern.rs @@ -1,8 +1,9 @@ -//! 管理实体搜索共用的 SQL 字面前缀转义 +//! PostgreSQL 包含搜索共用的 SQL 字面量转义,大小写规则由调用方决定 -pub(super) fn literal_prefix_pattern(value: &str) -> String { - let mut escaped = String::with_capacity(value.len().saturating_add(1)); - for character in value.to_lowercase().chars() { +pub(super) fn literal_contains_pattern(value: &str) -> String { + let mut escaped = String::with_capacity(value.len().saturating_add(2)); + escaped.push('%'); + for character in value.chars() { if matches!(character, '\\' | '%' | '_') { escaped.push('\\'); } diff --git a/backend/crates/gateway-store/tests/postgres/analytics.rs b/backend/crates/gateway-store/tests/postgres/analytics.rs index 32ccc03cd..dc9fea1ad 100644 --- a/backend/crates/gateway-store/tests/postgres/analytics.rs +++ b/backend/crates/gateway-store/tests/postgres/analytics.rs @@ -455,8 +455,8 @@ async fn insights_share_bucket_states_without_merging_exact_percentiles() { } #[tokio::test] -async fn prefix_search_ignores_request_ids_and_matches_snapshot_names() { - let Some(database) = TestDatabase::create("analytics_search_prefix").await else { +async fn contains_search_ignores_request_ids_and_matches_snapshot_names() { + let Some(database) = TestDatabase::create("analytics_search_contains").await else { return; }; let start = Utc::now() - TimeDelta::hours(1); @@ -471,7 +471,7 @@ async fn prefix_search_ignores_request_ids_and_matches_snapshot_names() { ObservabilityRange::new(start - TimeDelta::seconds(1), start + TimeDelta::seconds(1)) .unwrap(), UsageRecordFilter { - search: Some("literal_".to_owned()), + search: Some("eral_".to_owned()), ..Default::default() }, ) diff --git a/backend/crates/gateway-store/tests/postgres/client_keys.rs b/backend/crates/gateway-store/tests/postgres/client_keys.rs index 0793fe393..399d576ec 100644 --- a/backend/crates/gateway-store/tests/postgres/client_keys.rs +++ b/backend/crates/gateway-store/tests/postgres/client_keys.rs @@ -256,7 +256,7 @@ async fn client_key_search_matches_names_and_labels_but_never_credential_values( let generated = format!("sk_{}", "K".repeat(43)); for (id, name, key) in [ ("key_generated", "Production", generated.as_str()), - ("key_custom", "生产_%专用", "legacy-token+/123"), + ("key_custom", r"生产_%专用\测试", "legacy-token+/123"), ] { sqlx::query( "insert into client_api_keys (id, name, label, key, created_at, updated_at) @@ -272,9 +272,17 @@ async fn client_key_search_matches_names_and_labels_but_never_credential_values( let repository = PgClientApiKeyRepository::new(database.pool.clone()); for (search, expected) in [ ("PROD", 1), + ("DUCt", 1), + ("tion", 1), ("生产_%", 1), + ("_%专", 1), + ("%", 1), + ("_", 1), + (r"\", 1), ("生产X", 0), ("backend", 2), + ("END LAB", 2), + ("label", 2), (generated.as_str(), 0), (&generated[..10], 0), ("legacy-token+/123", 0), diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 43ab3fe36..da4aa5dec 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -498,37 +498,6 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() { database.close().await; } -#[tokio::test] -async fn usage_search_should_match_literal_prefix_instead_of_substring() { - let Some(database) = TestDatabase::create("usage_literal_prefix_search").await else { - return; - }; - let now = Utc::now(); - seed_observability_facts(&database.pool, now) - .await - .expect("seed observability facts"); - let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) - .expect("observability range"); - - super::drain_analytics(&database.pool).await; - let page = observability_repository(&database.pool) - .list_usage_records(UsageRecordQuery { - status: Default::default(), - range, - filter: UsageRecordFilter { - search: Some("rimary".to_owned()), - ..UsageRecordFilter::default() - }, - current_page: 1, - page_size: ObservabilityPageSize::new(10).expect("page size"), - }) - .await - .expect("usage substring search"); - - assert_eq!(page.total, 0); - database.close().await; -} - #[tokio::test] async fn usage_search_should_ignore_request_ids_and_preserve_exact_filters() { let Some(database) = TestDatabase::create("usage_search_without_request_ids").await else { @@ -576,8 +545,8 @@ async fn usage_search_should_ignore_request_ids_and_preserve_exact_filters() { } #[tokio::test] -async fn usage_search_should_match_account_email_and_name_prefixes() { - let Some(database) = TestDatabase::create("usage_account_prefix").await else { +async fn usage_search_should_match_account_email_and_name_substrings() { + let Some(database) = TestDatabase::create("usage_account_contains").await else { return; }; let now = Utc::now(); @@ -587,10 +556,18 @@ async fn usage_search_should_match_account_email_and_name_prefixes() { let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) .expect("observability range"); - for search in ["account@example.invalid", "account@", "primary", "pri"] { + for search in [ + "account@example.invalid", + "account@", + "primary", + "pri", + "count@example.invalid", + "example.invalid", + "rimary", + ] { assert_usage_search_ids(&database.pool, range, search, &["req_observe_success"]).await; } - for search in ["count@example.invalid", "rimary", "missing@example.invalid"] { + for search in ["missing@example.invalid", "EXAMPLE.INVALID", "PRIMARY"] { assert_usage_search_ids(&database.pool, range, search, &[]).await; } @@ -679,9 +656,13 @@ async fn usage_search_should_treat_account_snapshot_wildcards_as_literals() { "account_team%tag@example.invalid", "account_", "account_team%", + "team%tag", + "_team%", r"primary\", r"primary\ops_", r"primary\ops_100%", + r"\ops_", + "100%", ] { assert_usage_search_ids(&database.pool, range, search, &["req_observe_success"]).await; } @@ -761,6 +742,9 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() { for (search, expected) in [ ("production", 1), ("PRODUCTION_%", 1), + ("DUCTION", 1), + ("_%专", 1), + ("专用", 1), ("ProductionX", 0), (key, 0), (&key[..10], 0), @@ -863,8 +847,8 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() { } #[tokio::test] -async fn ops_search_should_treat_sql_wildcards_as_literals() { - let Some(database) = TestDatabase::create("ops_literal_wildcard_search").await else { +async fn ops_search_should_match_literal_substrings_from_both_sources() { + let Some(database) = TestDatabase::create("ops_literal_contains_search").await else { return; }; let now = Utc::now(); @@ -875,20 +859,38 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() { .expect("observability range"); super::drain_analytics(&database.pool).await; - let page = observability_repository(&database.pool) - .list_ops_errors(OpsErrorQuery { - range, - filter: OpsErrorFilter { - search: Some("req%".to_owned()), - ..OpsErrorFilter::default() - }, - current_page: 1, - page_size: ObservabilityPageSize::new(10).expect("page size"), - }) - .await - .expect("ops literal wildcard search"); - - assert_eq!(page.total, 0); + let repository = observability_repository(&database.pool); + for (search, expected) in [ + ("observe_failed", 2), + ("_limit", 2), + ("observe_retry", 1), + ("OBSERVE", 0), + ("req%", 0), + ("rate%limit", 0), + ("rate__limit", 0), + (r"\", 0), + ] { + for current_page in 1..=3 { + let page = repository + .list_ops_errors(OpsErrorQuery { + range, + filter: OpsErrorFilter { + search: Some(search.to_owned()), + ..OpsErrorFilter::default() + }, + current_page, + page_size: ObservabilityPageSize::new(1).expect("page size"), + }) + .await + .expect("ops literal substring search"); + assert_eq!(page.total, expected, "search: {search}"); + assert_eq!( + page.items.len(), + usize::from(u64::from(current_page) <= expected), + "search: {search}, page: {current_page}" + ); + } + } database.close().await; } diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs index 46e763140..b8ee94cde 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs @@ -91,6 +91,60 @@ async fn account_capacity_should_resolve_limits_for_list_and_detail() { database.close().await; } +#[tokio::test] +async fn account_search_matches_only_name_and_email_literal_substrings() { + let Some(database) = TestDatabase::create("account_contains_search").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let mut record = account("acct_searchable", "upstream-user-needle"); + record.name = r"Team\Ops_100%".to_owned(); + record.email = Some("maker@example.invalid".to_owned()); + record.upstream_account_id = Some("upstream-account-marker".to_owned()); + repository.insert_provider_account(record).await.unwrap(); + let mut other = account("acct_other", "user-other"); + other.email = None; + repository.insert_provider_account(other).await.unwrap(); + let store = admin_account_store(&database.pool); + for (search, expected) in [ + ("ma", 1), + ("ak", 1), + ("EXAMPLE", 1), + ("invalid", 1), + (r"\OPS_", 1), + ("_100%", 1), + ("%", 1), + (r"\", 1), + ("searchable", 0), + ("user-needle", 0), + ("account-marker", 0), + ("Ops_100_", 0), + ("missing", 0), + ] { + let page = store + .list_accounts( + AccountListQuery { + page: 1, + page_size: PageSize::new(10).unwrap(), + provider_kind: None, + group_filter: None, + search: Some(search.to_owned()), + status: None, + sort: None, + }, + Default::default(), + ) + .await + .unwrap(); + assert_eq!(page.total, expected, "search: {search}"); + assert_eq!(page.items.len() as u64, expected, "search: {search}"); + if expected == 1 { + assert_eq!(page.items[0].account.id, "acct_searchable"); + } + } + database.close().await; +} + #[tokio::test] async fn provider_filter_applies_before_pagination_even_on_an_empty_page() { let Some(database) = TestDatabase::create("account_provider_filters").await else { diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 2c5b29690..762a9e226 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -827,7 +827,7 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u page_size: PageSize::new(10).expect("page size"), provider_kind: Some(ProviderKind::new("openai").expect("Provider kind")), group_filter: None, - search: Some("ALPHA@EXAMPLE".to_owned()), + search: Some("PHA@EXAM".to_owned()), status: Some(AccountStatus::Normal), sort: None, }, @@ -866,11 +866,11 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u assert_eq!(rate_limited.summary.rate_limited, 1); assert_eq!(rate_limited.summary.normal, 0); - let no_contains_compatibility = store + let email_domain_page = store .list_accounts( AccountListQuery { - page: 1, - page_size: PageSize::new(10).expect("page size"), + page: 2, + page_size: PageSize::new(2).expect("page size"), provider_kind: None, group_filter: None, search: Some("example.invalid".to_owned()), @@ -880,8 +880,10 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u Default::default(), ) .await - .expect("use structured account prefix search"); - assert_eq!(no_contains_compatibility.total, 0); + .expect("search accounts by email domain before pagination"); + assert_eq!(email_domain_page.total, 6); + assert_eq!(email_domain_page.items.len(), 2); + assert_eq!(email_domain_page.summary, usage_page.summary); let error_accounts = store .list_accounts( diff --git a/docs/api.md b/docs/api.md index 7b877068f..44970b8d3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -779,6 +779,8 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 - `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt` - `sortDirection`: `asc`、`desc` +账号列表的 `search` 仅按字面量包含匹配名称和邮箱,不区分大小写;`%`、`_` 和 `\` 按普通字符处理 + 账号视图的 `capacity` 返回查询时的普通并发容量:`usedSlots` 不含独立审批池和排队请求, 读取运行态失败时为 `null`;`totalSlots` 是应用账号独立配置或全局默认值后的上限,`null` 表示不限。 该上限不代表上游实际允许的并发数 @@ -1428,7 +1430,7 @@ HTTP 返回 `403` 和 `image_generation_disabled`,WebSocket 每个 `response.c 创建时省略或 `null` 表示无覆盖;更新时省略保留、`null` 清除。与通用字段同时提交时必须一致,否则拒绝整个请求; 响应中的兼容字段从同一映射派生,不是第二份配置 -密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量前缀匹配。 +密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量包含匹配,`%`、`_` 和 `\` 按普通字符处理。 创建和更新时去除名称首尾空白,并按忽略大小写、首尾空格的名称查重,重复返回 `409`。 更新排除当前记录,并发写入同样执行查重;失败不会留下审计或配置版本变更。 历史重名数据不自动改名,已有凭据继续有效;再次保存时需使用未被其他密钥占用的名称 @@ -2111,7 +2113,7 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚 请求明细的 `clientApiKeyName`、`accountName` 按字面量包含匹配请求保存的历史名称快照,区分大小写, 不解析为 ID,也不依赖当前配置是否存在。`model` 按字面量包含匹配请求模型或上游模型,`%`、`_` 和 `\` 按普通字符处理 -运维错误列表的 `search` 使用字面量前缀匹配,不支持密钥值或可见前缀搜索。 +运维错误列表的 `search` 使用字面量包含匹配,`%`、`_` 和 `\` 按普通字符处理,不支持密钥值或可见前缀搜索。 请求详情通过 `/api/admin/usage/records/detail?id=...` 查询 ### 记录范围与统计口径