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=...` 查询 ### 记录范围与统计口径