diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 4b84b645..e5421862 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -3,6 +3,9 @@ import type { AdminDebugHttpResponse, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, + AdminDatabaseTableListResponse, + AdminDatabaseTableRowsQuery, + AdminDatabaseTableRowsResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -129,6 +132,23 @@ export function getAdminOverview(token: string) { return request('/admin/api/overview', {token}); } +export function getAdminDatabaseTables(token: string) { + return request('/admin/api/database/tables', { + token, + }); +} + +export function getAdminDatabaseTableRows( + token: string, + tableName: string, + query: AdminDatabaseTableRowsQuery = {}, +) { + return request( + `/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`, + {token}, + ); +} + export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { return request('/admin/api/debug/http', { method: 'POST', @@ -257,6 +277,17 @@ function buildQueryString(query: AdminTrackingEventListQuery) { return queryString ? `?${queryString}` : ''; } +function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'search', query.search); + appendQueryParam(params, 'filters', query.filters); + if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { + params.set('limit', String(query.limit)); + } + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function appendQueryParam( params: URLSearchParams, key: string, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 63ffbacf..7206e1d4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -72,6 +72,30 @@ export interface AdminDatabaseOverviewPayload { fetchErrors: string[]; } +export interface AdminDatabaseTableListResponse { + tables: string[]; + fetchErrors: string[]; +} + +export interface AdminDatabaseTableRowsQuery { + limit?: number; + search?: string; + filters?: string; +} + +export interface AdminDatabaseTableRowPayload { + cells: Record; + raw: unknown; +} + +export interface AdminDatabaseTableRowsResponse { + tableName: string; + columns: string[]; + rows: AdminDatabaseTableRowPayload[]; + totalReturned: number; + limit: number; +} + export interface AdminDatabaseTableStatPayload { tableName: string; rowCount: number | null; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index d1499ef0..9e24bd0d 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -18,6 +18,7 @@ import { setStoredAdminToken, } from '../auth/adminAuthStore'; import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; +import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; @@ -160,6 +161,12 @@ export function AdminApp() { {routeId === 'overview' ? ( ) : null} + {routeId === 'tables' ? ( + + ) : null} {routeId === 'debug' ? ( ) : null} diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 127ad02d..0230b527 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -4,6 +4,7 @@ import { LogOut, ShieldCheck, ListChecks, + Database, Table2, TicketCheck, TicketPercent, @@ -24,6 +25,7 @@ interface AdminShellProps { const routeIcons = { overview: LayoutDashboard, + tables: Database, debug: Bug, tracking: Table2, redeem: TicketPercent, diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index fc459c61..4296d9c6 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,4 +1,11 @@ -export type AdminRouteId = 'overview' | 'debug' | 'tracking' | 'redeem' | 'invite' | 'tasks'; +export type AdminRouteId = + | 'overview' + | 'tables' + | 'debug' + | 'tracking' + | 'redeem' + | 'invite' + | 'tasks'; export interface AdminRouteDefinition { id: AdminRouteId; @@ -8,6 +15,7 @@ export interface AdminRouteDefinition { export const adminRoutes: AdminRouteDefinition[] = [ {id: 'overview', label: '总览', hash: '#overview'}, + {id: 'tables', label: '表查询', hash: '#tables'}, {id: 'debug', label: 'API 调试', hash: '#debug'}, {id: 'tracking', label: '埋点数据', hash: '#tracking'}, {id: 'redeem', label: '兑换码', hash: '#redeem'}, @@ -16,7 +24,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ ]; export function resolveAdminRoute(hash: string): AdminRouteId { - const normalizedHash = hash.trim().toLowerCase(); + const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? ''; return ( adminRoutes.find((route) => route.hash === normalizedHash)?.id ?? 'overview' diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx new file mode 100644 index 00000000..91f38076 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -0,0 +1,314 @@ +import {Eye, RefreshCcw, Search, X} from 'lucide-react'; +import {FormEvent, useEffect, useMemo, useState} from 'react'; + +import { + getAdminDatabaseTableRows, + getAdminDatabaseTables, +} from '../api/adminApiClient'; +import type { + AdminDatabaseTableRowPayload, + AdminDatabaseTableRowsResponse, +} from '../api/adminApiTypes'; +import {handlePageError} from './pageUtils'; + +interface AdminDatabaseTablesPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +export function AdminDatabaseTablesPage({ + token, + onUnauthorized, +}: AdminDatabaseTablesPageProps) { + const [tables, setTables] = useState([]); + const [tableName, setTableName] = useState(() => readHashTableName()); + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState(''); + const [limit, setLimit] = useState('100'); + const [result, setResult] = useState(null); + const [detailRow, setDetailRow] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoadingTables, setIsLoadingTables] = useState(false); + const [isLoadingRows, setIsLoadingRows] = useState(false); + + useEffect(() => { + void loadTables(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + useEffect(() => { + const nextTableName = readHashTableName(); + if (nextTableName) { + setTableName(nextTableName); + } + const handleHashChange = () => { + const tableFromHash = readHashTableName(); + if (tableFromHash) { + setTableName(tableFromHash); + void refreshRows(tableFromHash); + } + }; + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (tables.length && !tableName) { + setTableName(tables[0] ?? ''); + } + }, [tableName, tables]); + + useEffect(() => { + if (tableName) { + void refreshRows(tableName); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableName]); + + const visibleColumns = useMemo(() => { + const columns = result?.columns ?? []; + if (columns.length) { + return columns; + } + const firstRow = result?.rows[0]; + return firstRow ? Object.keys(firstRow.cells) : []; + }, [result]); + + async function loadTables() { + setIsLoadingTables(true); + setErrorMessage(''); + try { + const response = await getAdminDatabaseTables(token); + setTables(response.tables); + if (response.fetchErrors.length) { + setErrorMessage(response.fetchErrors.join(';')); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoadingTables(false); + } + } + + async function refreshRows(nextTableName = tableName) { + const normalizedTableName = nextTableName.trim(); + if (!normalizedTableName) { + return; + } + setIsLoadingRows(true); + setErrorMessage(''); + try { + const response = await getAdminDatabaseTableRows(token, normalizedTableName, { + search, + filters, + limit: parseLimit(limit), + }); + setResult(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoadingRows(false); + } + } + + function handleSearch(event: FormEvent) { + event.preventDefault(); + void refreshRows(); + } + + function handleTableChange(nextTableName: string) { + setTableName(nextTableName); + const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`; + if (window.location.hash !== nextHash) { + window.location.hash = nextHash; + } + } + + return ( +
+
+
+

表查询

+

SpacetimeDB 行数据

+
+
+ + +
+
+ +
+
+ + + + + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+

{result?.tableName || tableName || '数据行'}

+ {result?.totalReturned ?? 0} 条 +
+
+ + + + {visibleColumns.map((column) => ( + + ))} + + + + + {result?.rows.length ? ( + result.rows.map((row, rowIndex) => ( + setDetailRow(row)} + > + {visibleColumns.map((column) => ( + + ))} + + + )) + ) : ( + + + + )} + +
{column}详情
{formatCellValue(row.cells[column])} + +
暂无数据
+
+
+ + {detailRow ? ( +
+
+
+

行详情

+ +
+
+              {JSON.stringify(detailRow.cells, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} + +function readHashTableName() { + const hash = window.location.hash; + const queryIndex = hash.indexOf('?'); + if (queryIndex < 0) { + return ''; + } + return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? ''; +} + +function parseLimit(value: string) { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) ? parsed : 100; +} + +function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) { + const firstValue = Object.values(row.cells)[0]; + return `${rowIndex}-${String(firstValue ?? '')}`; +} + +function formatCellValue(value: unknown) { + if (value === null || typeof value === 'undefined' || value === '') { + return '-'; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return
{JSON.stringify(value, null, 2)}
; +} diff --git a/apps/admin-web/src/pages/AdminOverviewPage.tsx b/apps/admin-web/src/pages/AdminOverviewPage.tsx index 4a5f3ffa..654c2912 100644 --- a/apps/admin-web/src/pages/AdminOverviewPage.tsx +++ b/apps/admin-web/src/pages/AdminOverviewPage.tsx @@ -155,7 +155,17 @@ function InfoPanel({ function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) { return ( - {stat.tableName} + + + {typeof stat.rowCount === 'number' ? stat.rowCount : '-'} {stat.errorMessage ? ( diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 50f08553..faf13802 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -302,6 +302,28 @@ button:disabled { align-items: end; } +.admin-table-query-grid { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(220px, 1.2fr) minmax(96px, 0.45fr) auto; + gap: 12px; + align-items: end; +} + +.admin-table tbody tr[data-clickable="true"] { + cursor: pointer; +} + +.admin-table tbody tr[data-clickable="true"]:hover { + background: #f5fafb; +} + +.admin-text-button:hover, +.admin-text-button:focus-visible { + color: #126e82; + text-decoration: underline; + outline: none; +} + .admin-action-row { display: flex; flex-wrap: wrap; @@ -811,7 +833,8 @@ button:disabled { .admin-two-column, .admin-two-column-wide, .admin-form-row, - .admin-filter-grid { + .admin-filter-grid, + .admin-table-query-grid { grid-template-columns: 1fr; } diff --git a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md new file mode 100644 index 00000000..ede97600 --- /dev/null +++ b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md @@ -0,0 +1,88 @@ +# 后台数据库表查询技术方案(2026-05-08) + +## 背景 + +后台“总览”页已经通过 `/admin/api/overview` 展示 SpacetimeDB 表统计,但只能看到表名、行数和统计状态。运营和排障时需要从统计行直接进入单表查询页,按基础条件快速查看真实行数据。 + +## 目标 + +- 在后台新增“表查询”页,支持所有 schema 表的只读查询。 +- “总览 / 表统计”中的每一行可点击跳转到对应表的查询页。 +- 提供基础查询能力:表选择、关键词搜索、JSON 条件过滤、条数限制、刷新、查看行详情。 +- 不修改 SpacetimeDB 表结构,不新增 reducer,不引入写操作。 + +## 后端接口 + +### `GET /admin/api/database/tables` + +鉴权:沿用 `require_admin_auth`。 + +数据来源:SpacetimeDB schema HTTP API。 + +响应: + +```json +{ + "tables": ["tracking_event", "user_account"], + "fetchErrors": [] +} +``` + +### `GET /admin/api/database/tables/{tableName}/rows` + +鉴权:沿用 `require_admin_auth`。 + +Query: + +- `limit`:默认 100,范围 1-500。 +- `search`:可选,前端关键词;后端返回行后在 JSON 文本中大小写不敏感过滤。 +- `filters`:可选 JSON object 字符串,例如 `{"user_id":"u1","enabled":true}`;后端返回行后按字段等值过滤。 + +响应: + +```json +{ + "tableName": "tracking_event", + "columns": ["event_id", "event_key"], + "rows": [ + { + "cells": { + "event_id": "event-1", + "event_key": "daily_login" + }, + "raw": ["event-1", "daily_login"] + } + ], + "totalReturned": 1, + "limit": 100 +} +``` + +实现约束: + +- 表名必须来自 schema 且通过标识符安全校验,避免任意 SQL 注入。 +- 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 或原始数组文本。 + +## 前端页面 + +路由:`#tables`,导航名“表查询”。 + +页面能力: + +- 表选择下拉,支持 URL hash `#tables?table=xxx` 直达指定表。 +- 查询表单:表名、关键词、JSON 条件、条数。 +- 查询结果表格横向滚动,移动端不撑坏布局。 +- 每行提供“详情”按钮,以独立弹层展示完整 JSON。 +- 总览表统计行点击后跳转到 `#tables?table={tableName}`。 + +## 验收 + +- `cd server-rs && cargo fmt -p api-server -p shared-contracts --check` +- `cd server-rs && cargo test -p api-server admin_database -- --nocapture` +- `npm run admin-web:typecheck` +- `npm run admin-web:build` +- `npm run check:encoding` +- `git diff --check` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index a0c5efe6..bdcd9b20 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -16,12 +16,14 @@ use axum::{ }; use reqwest::Client; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; use shared_contracts::admin::{ - AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, - AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, - AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, - AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, + AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, + AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, + AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, + AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, + AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, + AdminTrackingEventListResponse, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -46,6 +48,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[ const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9"; const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200; const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000; +const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100; +const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { @@ -170,6 +174,26 @@ pub async fn admin_list_tracking_events( )) } +pub async fn admin_list_database_tables( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let response = fetch_admin_database_table_list(&state).await?; + Ok(json_success_body(Some(&request_context), response)) +} + +pub async fn admin_list_database_table_rows( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + axum::extract::Path(table_name): axum::extract::Path, + Query(query): Query, +) -> Result, AppError> { + let response = fetch_admin_database_table_rows(&state, &table_name, query).await?; + Ok(json_success_body(Some(&request_context), response)) +} + pub async fn require_admin_auth( State(state): State, mut request: Request, @@ -263,21 +287,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo .ok() .flatten(); - let schema_table_names = schema - .as_ref() - .and_then(|value| value.tables.as_ref()) - .map(|tables| { - tables - .iter() - .filter_map(|table| table.name.as_deref()) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(ToOwned::to_owned) - .collect::>() - .into_iter() - .collect::>() - }) - .unwrap_or_default(); + let schema_table_names = extract_schema_table_names(schema.as_ref()); let mut table_stats = Vec::new(); for table_name in &schema_table_names { @@ -505,6 +515,275 @@ fn parse_count_value(value: &Value) -> Result { } } +async fn fetch_admin_database_table_list( + state: &AppState, +) -> Result { + let (_, tables, fetch_errors) = fetch_admin_database_schema_tables(state).await; + Ok(AdminDatabaseTableListResponse { + tables, + fetch_errors, + }) +} + +async fn fetch_admin_database_table_rows( + state: &AppState, + table_name: &str, + query: AdminDatabaseTableRowsQuery, +) -> Result { + let table_name = table_name.trim(); + if !is_safe_spacetime_table_name(table_name) { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("表名不合法")); + } + + let (_, 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 client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = resolve_admin_spacetime_sql_token(state); + let limit = clamp_admin_database_table_limit(query.limit); + let sql = format!("SELECT * FROM {table_name} LIMIT {limit}"); + let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!( + "表数据读取失败:{}", + normalize_table_count_error(&error) + )) + })?; + let mut 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}")) + })?; + apply_admin_database_table_filters(&mut response.rows, &query)?; + response.total_returned = response.rows.len(); + Ok(response) +} + +async fn fetch_admin_database_schema_tables( + state: &AppState, +) -> (Option, Vec, Vec) { + let client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = resolve_admin_spacetime_sql_token(state); + let mut fetch_errors = Vec::new(); + let schema = fetch_spacetime_json::( + &client, + &build_spacetime_schema_url(server_root, database), + token.as_deref(), + ) + .await + .map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}"))) + .ok() + .flatten(); + let tables = extract_schema_table_names(schema.as_ref()); + (schema, tables, fetch_errors) +} + +fn extract_schema_table_names(schema: Option<&SpacetimeSchemaResponse>) -> Vec { + schema + .and_then(|value| value.tables.as_ref()) + .map(|tables| { + tables + .iter() + .filter_map(|table| table.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .collect::>() + }) + .unwrap_or_default() +} + +fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option { + state + .config + .spacetime_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(load_local_spacetime_cli_token) +} + +fn clamp_admin_database_table_limit(limit: Option) -> u32 { + limit + .unwrap_or(ADMIN_DATABASE_TABLE_DEFAULT_LIMIT) + .clamp(1, ADMIN_DATABASE_TABLE_MAX_LIMIT) +} + +fn parse_admin_database_table_rows_sql_response( + table_name: &str, + limit: u32, + payload: Value, +) -> Result { + let statement = extract_first_sql_statement(payload)?; + let columns = extract_sql_statement_columns(&statement); + let rows_value = statement + .get("rows") + .ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?; + let row_values = rows_value + .as_array() + .ok_or_else(|| "SQL rows 字段格式非法".to_string())?; + let rows = row_values + .iter() + .map(|row| build_admin_database_table_row(row, &columns)) + .collect::>(); + Ok(AdminDatabaseTableRowsResponse { + table_name: table_name.to_string(), + columns, + total_returned: rows.len(), + rows, + limit, + }) +} + +fn extract_first_sql_statement(payload: Value) -> Result { + match payload { + Value::Array(statements) => statements + .into_iter() + .next() + .ok_or_else(|| "SQL 结果为空".to_string()), + Value::Object(statement) => Ok(Value::Object(statement)), + _ => Err("SQL 响应格式非法".to_string()), + } +} + +fn extract_sql_statement_columns(statement: &Value) -> Vec { + statement + .get("schema") + .and_then(|schema| schema.get("elements")) + .and_then(Value::as_array) + .map(|elements| { + elements + .iter() + .enumerate() + .map(|(index, element)| { + element + .get("name") + .and_then(extract_sql_schema_name) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("col_{}", index + 1)) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { + let raw = normalize_admin_database_value(row); + let mut cells = Map::new(); + if let Some(values) = row.as_array() { + for (index, value) in values.iter().enumerate() { + let key = columns + .get(index) + .cloned() + .unwrap_or_else(|| format!("col_{}", index + 1)); + cells.insert(key, normalize_admin_database_value(value)); + } + } else if let Some(object) = row.as_object() { + for (key, value) in object { + cells.insert(key.clone(), normalize_admin_database_value(value)); + } + } + AdminDatabaseTableRowPayload { + cells: Value::Object(cells), + raw, + } +} + +fn normalize_admin_database_value(value: &Value) -> Value { + match value { + Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]), + Value::Array(items) if items.len() == 2 => { + if let Some(index) = items.first().and_then(Value::as_u64) { + if index == 0 { + return items + .get(1) + .map(normalize_admin_database_value) + .unwrap_or(Value::Null); + } + if index == 1 && items.get(1).and_then(Value::as_array).is_some() { + return Value::Null; + } + } + Value::Array(items.iter().map(normalize_admin_database_value).collect()) + } + Value::Array(items) => { + Value::Array(items.iter().map(normalize_admin_database_value).collect()) + } + Value::Object(object) => { + if let Some(value) = object.get("some") { + return normalize_admin_database_value(value); + } + Value::Object( + object + .iter() + .map(|(key, value)| (key.clone(), normalize_admin_database_value(value))) + .collect(), + ) + } + _ => value.clone(), + } +} + +fn apply_admin_database_table_filters( + rows: &mut Vec, + query: &AdminDatabaseTableRowsQuery, +) -> Result<(), AppError> { + if let Some(search) = normalized_non_empty(query.search.as_deref()) { + let needle = search.to_ascii_lowercase(); + rows.retain(|row| row.cells.to_string().to_ascii_lowercase().contains(&needle)); + } + + if let Some(filters) = normalized_non_empty(query.filters.as_deref()) { + let parsed = serde_json::from_str::(filters).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("筛选 JSON 解析失败:{error}")) + })?; + let object = parsed.as_object().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("筛选条件必须是 JSON object") + })?; + rows.retain(|row| row_matches_admin_database_filters(row, object)); + } + Ok(()) +} + +fn row_matches_admin_database_filters( + row: &AdminDatabaseTableRowPayload, + filters: &Map, +) -> bool { + let Some(cells) = row.cells.as_object() else { + return filters.is_empty(); + }; + filters.iter().all(|(key, expected)| { + cells + .get(key) + .map(|actual| admin_database_filter_value_matches(actual, expected)) + .unwrap_or(false) + }) +} + +fn admin_database_filter_value_matches(actual: &Value, expected: &Value) -> bool { + if actual == expected { + return true; + } + if let Some(expected_text) = expected.as_str() { + return value_to_string(actual) + .map(|actual_text| actual_text == expected_text) + .unwrap_or(false); + } + false +} + async fn fetch_admin_tracking_events( state: &AppState, query: AdminTrackingEventListQuery, @@ -949,14 +1228,16 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ + apply_admin_database_table_filters, build_admin_database_table_row, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, - build_spacetime_schema_url, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, - normalize_debug_path, normalize_table_count_error, + build_spacetime_schema_url, clamp_admin_database_table_limit, + clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path, + normalize_table_count_error, parse_admin_database_table_rows_sql_response, parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview, }; use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; - use shared_contracts::admin::AdminTrackingEventListQuery; + use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -1119,6 +1400,103 @@ mod tests { assert_eq!(count, 3); } + #[test] + fn clamp_admin_database_table_limit_uses_default_and_bounds() { + assert_eq!(clamp_admin_database_table_limit(None), 100); + assert_eq!(clamp_admin_database_table_limit(Some(0)), 1); + assert_eq!(clamp_admin_database_table_limit(Some(800)), 500); + } + + #[test] + fn parse_admin_database_table_rows_sql_response_maps_schema_columns() { + let payload = json!([ + { + "schema": { + "elements": [ + {"name": {"some": "user_id"}}, + {"name": {"some": "points"}} + ] + }, + "rows": [["u1", 12]] + } + ]); + + let response = parse_admin_database_table_rows_sql_response("profile_wallet", 100, payload) + .expect("table rows should parse"); + + assert_eq!(response.table_name, "profile_wallet"); + assert_eq!(response.columns, vec!["user_id", "points"]); + assert_eq!(response.total_returned, 1); + assert_eq!(response.rows[0].cells["user_id"], json!("u1")); + assert_eq!(response.rows[0].cells["points"], json!(12)); + } + + #[test] + fn build_admin_database_table_row_normalizes_optional_sats_values() { + let row = build_admin_database_table_row( + &json!([[0, "u1"], [1, []]]), + &["user_id".to_string(), "deleted_at".to_string()], + ); + + assert_eq!(row.cells["user_id"], json!("u1")); + assert_eq!(row.cells["deleted_at"], json!(null)); + } + + #[test] + fn apply_admin_database_table_filters_supports_search_and_json_filters() { + let mut rows = vec![ + build_admin_database_table_row( + &json!(["u1", "alice", 12]), + &[ + "user_id".to_string(), + "name".to_string(), + "points".to_string(), + ], + ), + build_admin_database_table_row( + &json!(["u2", "bob", 8]), + &[ + "user_id".to_string(), + "name".to_string(), + "points".to_string(), + ], + ), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: Some("ali".to_string()), + filters: Some(r#"{"points":12}"#.to_string()), + limit: None, + }, + ) + .expect("filters should apply"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cells["user_id"], json!("u1")); + } + + #[test] + fn apply_admin_database_table_filters_rejects_non_object_filter() { + let mut rows = vec![build_admin_database_table_row( + &json!(["u1"]), + &["user_id".to_string()], + )]; + + let error = apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some("[]".to_string()), + limit: None, + }, + ) + .expect_err("non object filter should fail"); + + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + #[test] fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() { let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4abf378e..29ea65d0 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -14,8 +14,8 @@ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ admin::{ - admin_debug_http, admin_list_tracking_events, admin_login, admin_me, admin_overview, - require_admin_auth, + admin_debug_http, admin_list_database_table_rows, admin_list_database_tables, + admin_list_tracking_events, admin_login, admin_me, admin_overview, require_admin_auth, }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, @@ -179,6 +179,20 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/database/tables", + get(admin_list_database_tables).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/database/tables/{table_name}/rows", + get(admin_list_database_table_rows).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes) diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 81fe5e33..122e0761 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -77,6 +77,42 @@ pub struct AdminDatabaseTableStatPayload { pub error_message: Option, } +// 后台表清单独立用于“表查询”页,避免页面必须先拉完整总览。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseTableListResponse { + pub tables: Vec, + pub fetch_errors: Vec, +} + +// 后台通用表查询参数,用户输入不进入 SQL,只在 API Server 内存中过滤。 +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseTableRowsQuery { + pub limit: Option, + pub search: Option, + pub filters: Option, +} + +// 后台通用表查询响应,cells 使用列名映射,raw 保留原始行便于详情排障。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseTableRowsResponse { + pub table_name: String, + pub columns: Vec, + pub rows: Vec, + pub total_returned: usize, + pub limit: u32, +} + +// 单行查询结果,值统一用 JSON 承载以兼容不同表字段类型。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseTableRowPayload { + pub cells: Value, + pub raw: Value, +} + // 调试请求只允许同源路径、受控请求头和有限请求体。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")]