修复 PR 232 后台表查询评审问题
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

后台筛选要求 eq/ne 必须提供标量 value,缺失时返回 400。

统一表单和标题区查询入口的筛选完整性校验。

行内交互控件键盘事件不再冒泡触发行详情。

空字段详情复制给出明确失败反馈。

补充后端与后台页面回归测试。

同步更新后台表查询契约说明与运维文档。
This commit is contained in:
2026-09-01 11:35:19 +08:00
parent 98e43b7957
commit 3a3df3aabb
5 changed files with 132 additions and 6 deletions
@@ -13,13 +13,13 @@
- `search` / `filters` 不进入 SQL 字符串:
- SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}`
- 返回后在 api-server 内存中过滤
- `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);非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 400
- `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);非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 400。数组中的 `eq` / `ne` 和其他标量运算符一样必须提供 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty`
- SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。
## 前端实现要点
- `adminRoutes` 必须新增 `tables``AdminShell.routeIcons` 也要同步覆盖。
- `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`
- 预览表格数据行直接点击(或聚焦后按 Enter / Space)打开详情,详情按钮不单独占列详情字段仅提供复制操作,复制反馈使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。
- 预览表格数据行直接点击(或行自身聚焦后按 Enter / Space)打开详情,行内按钮 / 输入控件的键盘操作不冒泡打开详情;详情按钮不单独占列详情字段仅提供复制操作,成功、剪贴板失败和空字段复制都使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。表单和标题区查询按钮共用筛选完整性校验。
- worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。
## 验证结果
@@ -72,6 +72,7 @@ const referralRows = [
];
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
@@ -372,6 +373,21 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户查看按钮上的键盘操作不会同时打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
userButton.focus();
await user.keyboard('{Enter}');
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
@@ -528,6 +544,22 @@ test('后台表查询页会提示未完成的启用条件而不是静默忽略',
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页顶部查询按钮会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getHeadingQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页列头筛选按钮会带字段添加条件', async () => {
const user = userEvent.setup();
render(
@@ -573,11 +605,55 @@ test('后台表查询页行详情仅保留复制操作', async () => {
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4);
});
test('后台表查询页空字段详情复制会明确提示无法复制空值', async () => {
const user = userEvent.setup();
const writeText = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'deleted_at'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-null',
deleted_at: null,
},
raw: ['row-null', null],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-null');
await user.click(screen.getByText('row-null').closest('tr')!);
await user.click(screen.getByRole('button', { name: '复制删除时间' }));
expect(screen.getByRole('alert').textContent).toBe('该字段为空,无法复制');
expect(writeText).not.toHaveBeenCalled();
});
function getFormSubmitQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[buttons.length - 1]!;
}
function getHeadingQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[0]!;
}
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
@@ -305,6 +305,15 @@ export function AdminDatabaseTablesPage({
void refreshRows(tableName, { page: 1 });
}
function handleQueryRows() {
const validationMessage = getDatabaseFilterValidationMessage(conditions);
if (validationMessage) {
setErrorMessage(validationMessage);
return;
}
void refreshRows(tableName, { page: 1 });
}
function handleTableChange(nextTableName: string) {
setSortColumn('');
setSortDirection('asc');
@@ -471,6 +480,9 @@ export function AdminDatabaseTablesPage({
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
if (isInteractiveFormControl(event.target)) {
return;
}
event.preventDefault();
setDetailRow(row);
}
@@ -495,7 +507,15 @@ export function AdminDatabaseTablesPage({
async function handleCopyDetailField(column: string, value: unknown) {
const copiedText = stringifyDetailValue(value);
if (!copiedText) {
if (
value === null ||
typeof value === 'undefined' ||
copiedText.trim() === ''
) {
setCopyToast({
message: '该字段为空,无法复制',
tone: 'error',
});
return;
}
try {
@@ -536,7 +556,7 @@ export function AdminDatabaseTablesPage({
className="admin-primary-button"
disabled={!tableName || isLoadingRows}
type="button"
onClick={() => void refreshRows(tableName, { page: 1 })}
onClick={handleQueryRows}
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
@@ -1281,6 +1301,13 @@ function focusConditionControl(index: number, target: 'column' | 'value') {
}, 0);
}
function isInteractiveFormControl(target: EventTarget | null) {
return (
target instanceof HTMLElement &&
target.closest('button, a, input, select, textarea') !== null
);
}
function getDatabaseTableHeader(tableName: string): DatabaseTableHeader {
const normalizedName = tableName.trim();
if (!normalizedName) {
@@ -903,7 +903,7 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
后台通用表查询的“每页条数”不是筛选前的 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 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。
后台表查询页的结构化筛选条件支持逐条勾选启用或停用;停用条件不会进入请求,但会保留当前字段和值。`in` / `notIn` 使用逐项值标签编辑,支持粘贴多行值,避免把字符串中的逗号误拆成多个条件。
后台表查询页的结构化筛选条件支持逐条勾选启用或停用;停用条件不会进入请求,但会保留当前字段和值。表单和标题区的查询入口共用同一完整性校验,未完成的启用条件不会静默丢弃。`eq` / `ne` 和比较、文本运算符一样必须提供标量 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty``in` / `notIn` 使用逐项值标签编辑,支持粘贴多行值,避免把字符串中的逗号误拆成多个条件。行内交互控件的键盘事件不会冒泡触发行详情,空字段详情复制会给出失败反馈。
## Issue 与交接
+24 -1
View File
@@ -3314,7 +3314,9 @@ impl AdminDatabaseFilterOperator {
fn requires_scalar_value(self) -> bool {
matches!(
self,
Self::Gt
Self::Eq
| Self::Ne
| Self::Gt
| Self::Gte
| Self::Lt
| Self::Lte
@@ -5807,6 +5809,8 @@ mod tests {
#[test]
fn apply_admin_database_table_filters_rejects_invalid_operator_forms() {
let invalid_filters = [
json!([{"column": "points", "op": "eq"}]).to_string(),
json!([{"column": "points", "op": "ne", "value": null}]).to_string(),
json!([{"column": "points", "op": "between", "value": "1"}]).to_string(),
json!([{"column": "points", "op": "in", "value": "1"}]).to_string(),
json!([{"column": "points", "op": "contains"}]).to_string(),
@@ -5832,6 +5836,25 @@ mod tests {
}
}
#[test]
fn apply_admin_database_table_filters_rejects_eq_without_required_value() {
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 {
filters: Some(json!([{"column": "user_id", "op": "eq"}]).to_string()),
..Default::default()
},
)
.expect_err("eq without value should fail as a malformed request");
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 {