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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-27 16:20:15 +08:00
co-authored by Codex
parent dd529d07ec
commit a48139b4c2
13 changed files with 150 additions and 98 deletions
@@ -1484,12 +1484,12 @@ async fn count_client_api_keys(pool: &PgPool, search: Option<&str>) -> StoreResu
fn push_client_key_search(statement: &mut QueryBuilder<Postgres>, 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(")");
}
@@ -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::*;
@@ -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,
};
@@ -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<Postgres>, columns: &[&str], value: &str) {
let pattern = literal_prefix_pattern(value);
fn push_contains_search(statement: &mut QueryBuilder<Postgres>, columns: &[&str], value: &str) {
let pattern = literal_contains_pattern(value);
statement.push(" and (");
for (index, column) in columns.iter().enumerate() {
if index > 0 {
@@ -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,
@@ -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)
@@ -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('\\');
}
@@ -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()
},
)
@@ -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),
@@ -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;
}
@@ -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 {
@@ -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(
+4 -2
View File
@@ -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=...` 查询
### 记录范围与统计口径