From 514365fdece9a8c130f474d5c685eb055b876187 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 20:07:03 +0800 Subject: [PATCH] fix(admin): decode recharge order enum cells --- .hermes/shared-memory/pitfalls.md | 8 + .../ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md | 2 +- server-rs/crates/api-server/src/admin.rs | 141 +++++++++++++++++- 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 5be6e6a2..208b1761 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -622,6 +622,14 @@ - 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 +## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码 + +- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 +- 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 `[variant_index, []]`;后台通用 normalizer 曾把任何 `[0, value]` 都当作 `Option::Some(value)` 展开,导致 `[0, []]` 最终只剩 `[]`。 +- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership`,`profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded`。 +- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。 +- 关联:`server-rs/crates/api-server/src/admin.rs`、`docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`。 + ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 - 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。 diff --git a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md index 8ed5c4af..1de20f68 100644 --- a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md +++ b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md @@ -70,7 +70,7 @@ Query: - SQL 固定为 `SELECT * FROM {tableName} LIMIT {limit}`;SpacetimeDB 2.2 HTTP SQL 不拼 `ORDER BY`。 - 用户输入不直接拼入 SQL;关键词和条件在 API Server 内存中过滤。 - private 表或 token 不可见时返回后台可读错误信息。 -- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值,enum 可保留 tag/name 或原始数组文本。 +- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值;已知业务枚举列应在 API Server 按表名和列名转换为业务字符串,例如 `profile_recharge_order.kind` 转为 `points` / `membership`,`profile_recharge_order.status` 转为 `pending` / `paid` / `failed` / `closed` / `refunded`。 ## 前端页面 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 739cdb41..cd72a6d0 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -725,7 +725,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(row, &columns)) + .map(|row| build_admin_database_table_row_for_table(table_name, row, &columns)) .collect::>(); Ok(AdminDatabaseTableRowsResponse { table_name: table_name.to_string(), @@ -769,7 +769,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec { } fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { - let raw = normalize_admin_database_value(row); + build_admin_database_table_row_for_table("", row, columns) +} + +fn build_admin_database_table_row_for_table( + table_name: &str, + row: &Value, + columns: &[String], +) -> AdminDatabaseTableRowPayload { + let raw = normalize_admin_database_table_row_raw(table_name, row, columns); let mut cells = Map::new(); if let Some(values) = row.as_array() { for (index, value) in values.iter().enumerate() { @@ -777,11 +785,17 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab .get(index) .cloned() .unwrap_or_else(|| format!("col_{}", index + 1)); - cells.insert(key, normalize_admin_database_value(value)); + cells.insert( + key.clone(), + normalize_admin_database_table_cell(table_name, &key, value), + ); } } else if let Some(object) = row.as_object() { for (key, value) in object { - cells.insert(key.clone(), normalize_admin_database_value(value)); + cells.insert( + key.clone(), + normalize_admin_database_table_cell(table_name, key, value), + ); } } AdminDatabaseTableRowPayload { @@ -790,6 +804,85 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab } } +fn normalize_admin_database_table_row_raw( + table_name: &str, + row: &Value, + columns: &[String], +) -> Value { + if let Some(values) = row.as_array() { + return Value::Array( + values + .iter() + .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) + }) + .collect(), + ); + } + + if let Some(object) = row.as_object() { + return Value::Object( + object + .iter() + .map(|(key, value)| { + ( + key.clone(), + normalize_admin_database_table_cell(table_name, key, value), + ) + }) + .collect(), + ); + } + + normalize_admin_database_value(row) +} + +fn normalize_admin_database_table_cell( + table_name: &str, + column_name: &str, + value: &Value, +) -> Value { + if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) { + return enum_value; + } + 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", + _ => 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 { + return None; + } + items.first()?.as_u64() +} + fn normalize_admin_database_value(value: &Value) -> Value { match value { Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]), @@ -1526,6 +1619,46 @@ mod tests { assert_eq!(response.rows[0].cells["points"], json!(12)); } + #[test] + fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() { + let payload = json!([ + { + "schema": { + "elements": [ + {"name": {"some": "order_id"}}, + {"name": {"some": "kind"}}, + {"name": {"some": "status"}}, + {"name": {"some": "paid_at"}} + ] + }, + "rows": [[ + "recharge:user_00000001:1778757456811099:points_60", + [0, []], + [0, []], + [1, []] + ]] + } + ]); + + let response = + parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload) + .expect("recharge order rows should parse"); + + let cells = &response.rows[0].cells; + assert_eq!(cells["kind"], json!("points")); + assert_eq!(cells["status"], json!("pending")); + assert_eq!(cells["paid_at"], json!(null)); + assert_eq!( + response.rows[0].raw, + json!([ + "recharge:user_00000001:1778757456811099:points_60", + "points", + "pending", + null + ]) + ); + } + #[test] fn build_admin_database_table_row_normalizes_optional_sats_values() { let row = build_admin_database_table_row(