From 1e0aeb95b06b0521347a66c4ed43739bf381f296 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:28:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=A1=A8=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=88=97=E6=8C=89=20schema=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=8F=98=E4=BD=93=E5=90=8D=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=8F=AA=E7=9C=8B=E6=95=B0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增按 SpacetimeDB schema 解析枚举展示名的实现:读取 typespace.types 与表的 product_type_ref,为每个「Sum 且变体全为单元变体」的列建立按变体索引排列的展示名,变体名归一到 snake_case - 支持 Option<枚举> 列:标记 optional,[0, [索引, []]] 出变体名、[1, []] 仍是空值;Option<普通值> 与带载荷的 Sum 继续走通用解码,单变体枚举同样出名字 - 删除只覆盖 profile_recharge_order.kind / status 的硬编码映射 normalize_admin_database_known_enum - 枚举展示名同时作用于行的 cells 与 raw,关键词搜索、结构化筛选和稳定排序都按展示名生效 - 表查询行解析与行构建改为接收枚举映射,schema 夹具用例覆盖直接枚举、Option 枚举、单变体枚举与未知表 - 同步开发运维文档的表查询口径段、后台 skill 的 SATS 展示参考与决策记录 - 验证:cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin(139 passed / 0 failed / 1 ignored);cargo check -p api-server;cargo fmt --all --check;npm run check:encoding;npm run check:doc-index;git diff --check;本地 dev schema 回放同一算法,85 张表 32 个直接枚举列 + 2 个 Option 枚举列全部解析出展示名、0 残留 --- .../spacetimedb-http-sql-sats-display.md | 11 + .../shared-memory/decision-log.md | 9 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- server-rs/crates/api-server/src/admin.rs | 367 +++++++++++++++--- 4 files changed, 332 insertions(+), 57 deletions(-) diff --git a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md index e6ba4d50c..916f60814 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md +++ b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md @@ -41,3 +41,14 @@ ## 注意 不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。 + +## 通用表查询页的枚举展示(2026-09-23 起) + +后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析: + +- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。 +- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。 +- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。 +- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。 + +因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5ad48cc18..cd5bdc2e0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9299,3 +9299,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`server-rs/crates/api-server/src/{admin.rs,admin_recharge.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/{AdminRechargeOrderPage.tsx,AdminRedeemCodePage.tsx}`、`apps/admin-web/src/components/AdminUserDetailDialog.tsx`、对应三个用例文件与后端架构数据契约文档。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server cumulative_recharge`(2 条新用例)与 `cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(220 passed),其中三个定向文件 33 passed(新增「未支付订单不显示实付金额,发放泥点单独成列」与「累计充值读取不到时展示未知,不用订单列表近似」)。 - 边界(未验证):未连真实生产库核对历史订单的累计充值数值,也未跑真实栈 API smoke。 + +## 2026-09-23 后台表查询枚举列按 schema 展示变体名,不再只看数值 + +- 背景:后台「表查询」页的枚举列直接落回通用解码,只有 `profile_recharge_order` 的 `kind` / `status` 做了硬编码映射,其余 20 多张表的枚举列(如 `profile_wallet_ledger.source_type`、`tracking_event.scope_kind`、`profile_membership.tier`)都显示成 SATS 原始数值,运营在后台看不到枚举值。 +- 决策(按 schema 自动解析):api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 SpacetimeDB schema 的 `typespace.types` 与表的 `product_type_ref`,对每个「`Sum` 且所有变体都是单元变体」的列生成「列名 → 按变体索引排列的展示名」;变体名归一到 snake_case,与后台既有枚举字符串(`points` / `paid` / `asset_operation_consume`)同口径,因此原硬编码映射的展示结果不变,新增表与新增枚举列不再需要改代码。 +- 决策(边界):`Option<枚举>` 列单独标记为可空(`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值),`Option<普通值>` 与带载荷的 Sum 不参与映射,继续走通用解码(`Some` 解包、`None` 归空、时间戳原样透出);单变体枚举同样要出名字。映射同时作用于 `cells` 与 `raw`,关键词搜索、结构化筛选和稳定排序都按展示名生效。schema 读取失败时表查询以「表不存在」失败,不会退回展示数字。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`(新增 schema 解析与 `build_admin_database_enum_labels`,删除 `normalize_admin_database_known_enum`,`parse_admin_database_table_rows_sql_response` / 行构建与归一化改为接收枚举映射)、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md`。前端与 DTO 不变。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin::tests`(86 passed,含新增 `admin_database_enum_labels_come_from_schema_variants` 与改写后的充值订单枚举用例);用本地 dev schema 逐表回放同一算法,85 张表里 32 个枚举列全部解析出展示名、0 个残留;`cargo check -p api-server`、`cargo fmt --all --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +- 边界(未验证):没有对真实 HTTP 表查询响应做端到端比对(本地 dev api-server 仍是改动前二进制,未重启)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ee9e75881..9df1a01d4 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1112,7 +1112,7 @@ SELECT * FROM profile_recharge_order WHERE status = 'expired' AND expiration_che SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC; ``` -后台通用表查询已经处理 SpacetimeDB 无载荷枚举的 SATS 形态。新增后台表展示时,枚举列优先按表名和列名做业务映射,再落回通用解码。 +后台通用表查询按 SpacetimeDB schema 自动处理无载荷枚举的 SATS 形态:api-server 读取 schema 的 typespace 与表的 `product_type_ref`,为每个“`Sum` 且变体全为单元变体(排除 `Option` 的 `some` / `none`)”的列建立按变体索引排列的展示名,变体名归一到 snake_case,与 `points` / `paid` / `asset_operation_consume` 等同口径;`Option<枚举>` 列单独标记为可空,`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值。该映射同时作用于行的 `cells` 和 `raw`,所以关键词搜索、字段筛选与排序都按展示名生效;`Option<普通值>`、带载荷的 Sum 和非枚举列继续走通用解码(`Some` 解包、`None` 归空、时间戳按原样透出)。新增表或新增枚举列不再需要改代码,前提是模块已发布且 schema 可读;schema 读取失败时表查询本身就以“表不存在”失败,不会退回按索引展示数字。 后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / 字段条件过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。`filters` 支持两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`,兼容旧入口)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符覆盖 `eq`、`ne`、`gt`、`gte`、`lt`、`lte`、`contains`、`notContains`、`startsWith`、`endsWith`、`in`、`notIn`、`isEmpty`、`isNotEmpty`,允许同列多条件,条件间为 AND);两种形式的用户输入都不进入 SQL,只在 API Server 内存中过滤。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 572ed19aa..432e82c0f 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -147,11 +147,19 @@ struct SpacetimeDatabaseInfoResponse { #[derive(Debug, Deserialize)] struct SpacetimeSchemaResponse { tables: Option>, + typespace: Option, } #[derive(Debug, Deserialize)] struct SpacetimeSchemaTable { name: Option, + product_type_ref: Option, +} + +// schema 的 algebraic type 是异构联合,保留原始值由解析层按需下钻,避免复制整套 BSATN 类型定义。 +#[derive(Debug, Deserialize)] +struct SpacetimeSchemaTypespace { + types: Option>, } impl AuthenticatedAdmin { @@ -3301,10 +3309,11 @@ async fn fetch_admin_database_table_rows( .with_message("external_api_key 必须使用专用安全查询接口")); } - let (_, tables, _) = fetch_admin_database_schema_tables(state).await; + let (schema, tables, _) = fetch_admin_database_schema_tables(state).await; if !tables.iter().any(|name| name == table_name) { return Err(AppError::from_status(StatusCode::NOT_FOUND).with_message("表不存在")); } + let enum_labels = build_admin_database_enum_labels(schema.as_ref(), table_name); let client = Client::builder() .timeout(ADMIN_DATABASE_TABLE_REQUEST_TIMEOUT) @@ -3334,11 +3343,12 @@ async fn fetch_admin_database_table_rows( normalize_table_count_error(&error) )) })?; - let response = parse_admin_database_table_rows_sql_response(table_name, limit, payload) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(format!("表数据解析失败:{error}")) - })?; + let response = + parse_admin_database_table_rows_sql_response(table_name, &enum_labels, limit, payload) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("表数据解析失败:{error}")) + })?; finalize_admin_database_table_rows_response(response, &query) } @@ -3380,6 +3390,160 @@ fn extract_schema_table_names(schema: Option<&SpacetimeSchemaResponse>) -> Vec`。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct AdminDatabaseEnumColumn { + optional: bool, + variants: Vec, +} + +impl AdminDatabaseEnumColumn { + /// 解析单元格里的 SATS 枚举值;`Option<枚举>` 要先剥掉外层 `some` / `none`。 + fn resolve(&self, value: &Value) -> Option<&str> { + let index = if self.optional { + let items = value.as_array()?; + if items.len() != 2 { + return None; + } + match items.first().and_then(Value::as_u64) { + Some(0) => extract_sats_enum_variant_index(items.get(1)?)?, + _ => return None, + } + } else { + extract_sats_enum_variant_index(value)? + }; + self.variants.get(index as usize).map(String::as_str) + } +} + +/// 表内 SATS 枚举列的可读变体名:列名 → 按变体索引排列的展示名。 +/// 展示名取 schema 声明的变体名并归一到 snake_case,与后台其它枚举字符串(`points`、`paid`、`asset_operation_consume`)同口径。 +fn build_admin_database_enum_labels( + schema: Option<&SpacetimeSchemaResponse>, + table_name: &str, +) -> BTreeMap { + let mut labels = BTreeMap::new(); + let Some(schema) = schema else { + return labels; + }; + let Some(table) = schema.tables.as_ref().and_then(|tables| { + tables + .iter() + .find(|table| table.name.as_deref() == Some(table_name)) + }) else { + return labels; + }; + let Some(product_type_ref) = table.product_type_ref else { + return labels; + }; + let Some(types) = schema + .typespace + .as_ref() + .and_then(|typespace| typespace.types.as_deref()) + else { + return labels; + }; + let Some(elements) = types + .get(product_type_ref) + .and_then(|value| value.get("Product")) + .and_then(|value| value.get("elements")) + .and_then(Value::as_array) + else { + return labels; + }; + for element in elements { + let Some(column) = element.get("name").and_then(schema_option_string) else { + continue; + }; + let Some(algebraic_type) = element.get("algebraic_type") else { + continue; + }; + if let Some(variant_labels) = admin_database_enum_variant_labels(algebraic_type, types) { + labels.insert( + column.to_string(), + AdminDatabaseEnumColumn { + optional: false, + variants: variant_labels, + }, + ); + } else if let Some(variant_labels) = + admin_database_optional_enum_variant_labels(algebraic_type, types) + { + labels.insert( + column.to_string(), + AdminDatabaseEnumColumn { + optional: true, + variants: variant_labels, + }, + ); + } + } + labels +} + +fn schema_option_string(value: &Value) -> Option<&str> { + value.get("some").and_then(Value::as_str) +} + +/// 只识别“全部是单元变体”的 SATS 枚举;`Option` 的 `some` / `none` 与带载荷的 Sum 交回既有归一化处理。 +fn admin_database_enum_variant_labels( + algebraic_type: &Value, + types: &[Value], +) -> Option> { + let type_value = match algebraic_type.get("Ref").and_then(Value::as_u64) { + Some(index) => types.get(index as usize)?, + None => algebraic_type, + }; + let variants = type_value.get("Sum")?.get("variants")?.as_array()?; + if variants.is_empty() { + return None; + } + let mut labels = Vec::with_capacity(variants.len()); + for variant in variants { + let name = variant.get("name").and_then(schema_option_string)?; + if matches!(name, "some" | "none") { + return None; + } + if !admin_database_sum_variant_is_unit(variant)? { + return None; + } + labels.push(normalize_admin_enum_value_text(name)); + } + Some(labels) +} + +fn admin_database_sum_variant_is_unit(variant: &Value) -> Option { + let elements = variant + .get("algebraic_type")? + .get("Product")? + .get("elements")? + .as_array()?; + Some(elements.is_empty()) +} + +/// `Option<无载荷枚举>` 列:`[0, [索引, []]]` 能取到变体名,`[1, []]` 仍是空值。 +fn admin_database_optional_enum_variant_labels( + algebraic_type: &Value, + types: &[Value], +) -> Option> { + let type_value = match algebraic_type.get("Ref").and_then(Value::as_u64) { + Some(index) => types.get(index as usize)?, + None => algebraic_type, + }; + let variants = type_value.get("Sum")?.get("variants")?.as_array()?; + if variants.len() != 2 { + return None; + } + let some = variants.first()?; + let none = variants.get(1)?; + if some.get("name").and_then(schema_option_string) != Some("some") + || none.get("name").and_then(schema_option_string) != Some("none") + { + return None; + } + admin_database_enum_variant_labels(some.get("algebraic_type")?, types) +} + fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option { state .config @@ -3564,6 +3728,7 @@ fn admin_database_table_row_stable_key(row: &AdminDatabaseTableRowPayload) -> St fn parse_admin_database_table_rows_sql_response( table_name: &str, + enum_labels: &BTreeMap, limit: u32, payload: Value, ) -> Result { @@ -3577,7 +3742,7 @@ fn parse_admin_database_table_rows_sql_response( .ok_or_else(|| "SQL rows 字段格式非法".to_string())?; let rows = row_values .iter() - .map(|row| build_admin_database_table_row_for_table(table_name, row, &columns)) + .map(|row| build_admin_database_table_row_with_enum_labels(enum_labels, row, &columns)) .collect::>(); Ok(AdminDatabaseTableRowsResponse { table_name: table_name.to_string(), @@ -3627,15 +3792,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec { #[cfg(test)] fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { - build_admin_database_table_row_for_table("", row, columns) + build_admin_database_table_row_with_enum_labels(&BTreeMap::new(), row, columns) } -fn build_admin_database_table_row_for_table( - table_name: &str, +fn build_admin_database_table_row_with_enum_labels( + enum_labels: &BTreeMap, row: &Value, columns: &[String], ) -> AdminDatabaseTableRowPayload { - let raw = normalize_admin_database_table_row_raw(table_name, row, columns); + let raw = normalize_admin_database_table_row_raw(enum_labels, row, columns); let mut cells = Map::new(); if let Some(values) = row.as_array() { for (index, value) in values.iter().enumerate() { @@ -3645,14 +3810,14 @@ fn build_admin_database_table_row_for_table( .unwrap_or_else(|| format!("col_{}", index + 1)); cells.insert( key.clone(), - normalize_admin_database_table_cell(table_name, &key, value), + normalize_admin_database_table_cell(&key, enum_labels, value), ); } } else if let Some(object) = row.as_object() { for (key, value) in object { cells.insert( key.clone(), - normalize_admin_database_table_cell(table_name, key, value), + normalize_admin_database_table_cell(key, enum_labels, value), ); } } @@ -3663,7 +3828,7 @@ fn build_admin_database_table_row_for_table( } fn normalize_admin_database_table_row_raw( - table_name: &str, + enum_labels: &BTreeMap, row: &Value, columns: &[String], ) -> Value { @@ -3674,7 +3839,7 @@ fn normalize_admin_database_table_row_raw( .enumerate() .map(|(index, value)| { let key = columns.get(index).map(String::as_str).unwrap_or_default(); - normalize_admin_database_table_cell(table_name, key, value) + normalize_admin_database_table_cell(key, enum_labels, value) }) .collect(), ); @@ -3687,7 +3852,7 @@ fn normalize_admin_database_table_row_raw( .map(|(key, value)| { ( key.clone(), - normalize_admin_database_table_cell(table_name, key, value), + normalize_admin_database_table_cell(key, enum_labels, value), ) }) .collect(), @@ -3698,42 +3863,19 @@ fn normalize_admin_database_table_row_raw( } fn normalize_admin_database_table_cell( - table_name: &str, column_name: &str, + enum_labels: &BTreeMap, value: &Value, ) -> Value { - if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) { - return enum_value; + if let Some(label) = enum_labels + .get(column_name) + .and_then(|labels| labels.resolve(value)) + { + return Value::String(label.to_string()); } normalize_admin_database_value(value) } -fn normalize_admin_database_known_enum( - table_name: &str, - column_name: &str, - value: &Value, -) -> Option { - let variant_index = extract_sats_enum_variant_index(value)?; - let label = match (table_name, column_name) { - ("profile_recharge_order", "kind") => match variant_index { - 0 => "points", - 1 => "membership", - _ => return None, - }, - ("profile_recharge_order", "status") => match variant_index { - 0 => "pending", - 1 => "paid", - 2 => "failed", - 3 => "closed", - 4 => "refunded", - 5 => "expired", - _ => return None, - }, - _ => return None, - }; - Some(Value::String(label.to_string())) -} - fn extract_sats_enum_variant_index(value: &Value) -> Option { let items = value.as_array()?; if items.len() != 2 { @@ -4582,13 +4724,13 @@ fn tracking_scope_kind_to_string(value: &Value) -> Option { fn wallet_ledger_source_type_to_string(value: &Value) -> Option { match value { - Value::String(text) => Some(normalize_wallet_ledger_source_type_text(text)), + Value::String(text) => Some(normalize_admin_enum_value_text(text)), Value::Object(object) => object .get("tag") .or_else(|| object.get("variant")) .or_else(|| object.get("name")) .and_then(value_to_string) - .map(|value| normalize_wallet_ledger_source_type_text(&value)), + .map(|value| normalize_admin_enum_value_text(&value)), Value::Array(items) => { let index = items.first().and_then(Value::as_u64)?; Some( @@ -4614,11 +4756,11 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { .to_string(), ) } - _ => value_to_string(value).map(|value| normalize_wallet_ledger_source_type_text(&value)), + _ => value_to_string(value).map(|value| normalize_admin_enum_value_text(&value)), } } -fn normalize_wallet_ledger_source_type_text(value: &str) -> String { +fn normalize_admin_enum_value_text(value: &str) -> String { let trimmed = value.trim(); let mut normalized = String::new(); for (index, character) in trimmed.chars().enumerate() { @@ -4900,12 +5042,13 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess mod tests { use super::{ AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, - EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, + EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, apply_admin_database_table_filters, build_admin_asset_read_url_audit, build_admin_dashboard_chart, - build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, + build_admin_database_enum_labels, build_admin_database_table_row, + build_admin_editor_showcase_campaign_image_confirm_request, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, @@ -4923,6 +5066,8 @@ mod tests { validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, verify_admin_password, wallet_ledger_source_type_to_string, }; + use std::collections::BTreeMap; + use axum::{ http::{Method, StatusCode}, response::IntoResponse, @@ -6133,6 +6278,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 10, json!([{ "schema": { @@ -6171,6 +6317,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [ @@ -6200,6 +6347,7 @@ mod tests { let descending_response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [ @@ -6251,6 +6399,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 10, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6277,6 +6426,7 @@ mod tests { fn admin_database_table_query_clamps_page_and_keeps_empty_results_on_page_one() { let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6298,6 +6448,7 @@ mod tests { let empty_response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6333,8 +6484,13 @@ mod tests { } ]); - let response = parse_admin_database_table_rows_sql_response("profile_wallet", 100, payload) - .expect("table rows should parse"); + let response = parse_admin_database_table_rows_sql_response( + "profile_wallet", + &BTreeMap::new(), + 100, + payload, + ) + .expect("table rows should parse"); assert_eq!(response.table_name, "profile_wallet"); assert_eq!(response.columns, vec!["user_id", "points"]); @@ -6345,6 +6501,8 @@ mod tests { #[test] fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() { + let schema = admin_database_schema_with_recharge_order_enums(); + let labels = build_admin_database_enum_labels(Some(&schema), "profile_recharge_order"); let payload = json!([ { "schema": { @@ -6364,9 +6522,13 @@ mod tests { } ]); - let response = - parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload) - .expect("recharge order rows should parse"); + let response = parse_admin_database_table_rows_sql_response( + "profile_recharge_order", + &labels, + 100, + payload, + ) + .expect("recharge order rows should parse"); let cells = &response.rows[0].cells; assert_eq!(cells["kind"], json!("points")); @@ -6383,6 +6545,99 @@ mod tests { ); } + /// 枚举列的展示名来自 schema 变体:`Option` 与带载荷的 Sum 不参与,未知表不返回标签。 + #[test] + fn admin_database_enum_labels_come_from_schema_variants() { + let schema = admin_database_schema_with_recharge_order_enums(); + + let labels = build_admin_database_enum_labels(Some(&schema), "profile_recharge_order"); + + assert_eq!( + labels.get("kind").map(|column| &column.variants), + Some(&vec!["points".to_string(), "membership".to_string()]) + ); + assert_eq!( + labels.get("status").map(|column| &column.variants), + Some(&vec!["pending".to_string(), "paid".to_string()]) + ); + assert_eq!( + labels.get("source_type").map(|column| &column.variants), + Some(&vec![ + "snapshot_sync".to_string(), + "llm_router_consume".to_string() + ]) + ); + // 单变体枚举同样要出名字,不能退回原始数值。 + assert_eq!( + labels.get("mode").map(|column| &column.variants), + Some(&vec!["public".to_string()]) + ); + // Option<枚举> 列要标出 optional,并支持 [0, [索引, []]] 与 [1, []] 两种形态。 + let optional = labels + .get("task_status") + .expect("optional enum column should resolve"); + assert!(optional.optional); + assert_eq!(optional.resolve(&json!([0, [1, []]])), Some("paid")); + assert_eq!(optional.resolve(&json!([1, []])), None); + assert_eq!(labels.get("paid_at"), None); + assert_eq!(labels.get("order_id"), None); + assert!(build_admin_database_enum_labels(Some(&schema), "missing_table").is_empty()); + assert!(build_admin_database_enum_labels(None, "profile_recharge_order").is_empty()); + } + + /// schema 夹具按真实 HTTP schema 形态构造:列类型用 `Ref` 指向 typespace 的 `Sum`。 + fn admin_database_schema_with_recharge_order_enums() -> SpacetimeSchemaResponse { + serde_json::from_value(json!({ + "typespace": { + "types": [ + {"Sum": {"variants": [ + {"name": {"some": "points"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "membership"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "pending"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "paid"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Product": {"elements": [ + {"name": {"some": "__timestamp_micros_since_unix_epoch__"}, "algebraic_type": {"I64": []}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "snapshotSync"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "llmRouterConsume"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"String": []}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "public"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"Ref": 1}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Product": {"elements": [ + {"name": {"some": "order_id"}, "algebraic_type": {"String": []}}, + {"name": {"some": "kind"}, "algebraic_type": {"Ref": 0}}, + {"name": {"some": "status"}, "algebraic_type": {"Ref": 1}}, + {"name": {"some": "paid_at"}, "algebraic_type": {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"Ref": 2}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}}, + {"name": {"some": "source_type"}, "algebraic_type": {"Ref": 3}}, + {"name": {"some": "provider_transaction_id"}, "algebraic_type": {"Ref": 4}}, + {"name": {"some": "mode"}, "algebraic_type": {"Ref": 5}}, + {"name": {"some": "task_status"}, "algebraic_type": {"Ref": 6}} + ]}} + ] + }, + "tables": [ + {"name": "profile_recharge_order", "product_type_ref": 7} + ] + })) + .expect("schema fixture should deserialize") + } + #[test] fn build_admin_database_table_row_normalizes_optional_sats_values() { let row = build_admin_database_table_row(