修复后台表查询筛选与分页

使用单次哨兵查询统一计算扫描范围、匹配总数和截断状态
增加完整候选集服务端排序与越界页码钳制
增加固定分页栏、扫描上限提示和后端排序交互
补充大表响应边界文档与前后端回归测试
This commit is contained in:
2026-07-13 13:09:53 +08:00
parent 7becd38117
commit 06aa99e3eb
9 changed files with 866 additions and 221 deletions
+5
View File
@@ -764,6 +764,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
if (typeof query.page === 'number' && Number.isFinite(query.page)) {
params.set('page', String(query.page));
}
appendQueryParam(params, 'sortColumn', query.sortColumn);
appendQueryParam(params, 'sortDirection', query.sortDirection);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
+8
View File
@@ -150,8 +150,11 @@ export interface AdminDatabaseTableListResponse {
export interface AdminDatabaseTableRowsQuery {
limit?: number;
page?: number;
search?: string;
filters?: string;
sortColumn?: string;
sortDirection?: 'asc' | 'desc';
}
export interface AdminDatabaseTableRowPayload {
@@ -165,6 +168,11 @@ export interface AdminDatabaseTableRowsResponse {
rows: AdminDatabaseTableRowPayload[];
totalReturned: number;
limit: number;
page: number;
totalMatched: number;
scannedCount: number;
scanLimit: number;
scanLimitReached: boolean;
}
export interface AdminDatabaseTableStatPayload {
@@ -19,6 +19,36 @@ vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(() => false),
}));
const referralRows = [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
];
beforeEach(() => {
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
@@ -28,41 +58,72 @@ beforeEach(() => {
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
rows: [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
],
rows: referralRows,
page: 1,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
});
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup();
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id'],
limit: 100,
page: 1,
rows: [
{
cells: { invitee_user_id: 'u-b' },
raw: ['u-b'],
},
],
scannedCount: 50000,
scanLimit: 50000,
scanLimitReached: true,
tableName: 'profile_referral_relation',
totalMatched: 250,
totalReturned: 1,
});
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
expect(pagination.closest('.admin-panel')).toBeNull();
expect(
container.querySelector('.admin-database-tables-page')?.lastElementChild,
).toBe(pagination);
expect(
screen.getByText(
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: '',
limit: 100,
page: 2,
search: '',
}),
);
});
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
@@ -89,13 +150,55 @@ test('后台表查询页支持宽表滚动容器和表头排序', async () => {
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'asc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'desc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
});
@@ -2,12 +2,21 @@ import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Eye,
RefreshCcw,
Search,
X,
} from 'lucide-react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import {
FormEvent,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
getAdminDatabaseTableRows,
@@ -30,6 +39,14 @@ export function AdminDatabaseTablesPage({
token,
onUnauthorized,
}: AdminDatabaseTablesPageProps) {
const pageRef = useRef<HTMLElement>(null);
const appliedQueryRef = useRef({
search: '',
filters: '',
limit: '100',
sortColumn: '',
sortDirection: 'asc' as SortDirection,
});
const [tables, setTables] = useState<string[]>([]);
const [tableName, setTableName] = useState(() => readHashTableName());
const [search, setSearch] = useState('');
@@ -61,7 +78,6 @@ export function AdminDatabaseTablesPage({
const tableFromHash = readHashTableName();
if (tableFromHash) {
setTableName(tableFromHash);
void refreshRows(tableFromHash);
}
};
window.addEventListener('hashchange', handleHashChange);
@@ -82,6 +98,45 @@ export function AdminDatabaseTablesPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
useLayoutEffect(() => {
const pageElement = pageRef.current;
if (!pageElement) {
return;
}
const bottomNav = document.querySelector<HTMLElement>('.admin-bottom-nav');
const pagination = pageElement.querySelector<HTMLElement>(
'.admin-database-pagination',
);
const updateFixedBarHeights = () => {
pageElement.style.setProperty(
'--admin-bottom-nav-height',
`${bottomNav?.getBoundingClientRect().height ?? 0}px`,
);
pageElement.style.setProperty(
'--admin-database-pagination-height',
`${pagination?.getBoundingClientRect().height ?? 0}px`,
);
};
updateFixedBarHeights();
window.addEventListener('resize', updateFixedBarHeights);
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(updateFixedBarHeights)
: null;
if (resizeObserver) {
if (bottomNav) {
resizeObserver.observe(bottomNav);
}
if (pagination) {
resizeObserver.observe(pagination);
}
}
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateFixedBarHeights);
};
}, [result]);
const visibleColumns = useMemo(() => {
const columns = result?.columns ?? [];
if (columns.length) {
@@ -117,27 +172,11 @@ export function AdminDatabaseTablesPage({
[tableName, visibleColumns],
);
const sortedRows = useMemo(() => {
const rows = result?.rows ?? [];
if (!sortColumn || !visibleColumns.includes(sortColumn)) {
return rows;
}
return [...rows]
.map((row, index) => ({ index, row }))
.sort((left, right) => {
const comparison = compareTableCellValues(
left.row.cells[sortColumn],
right.row.cells[sortColumn],
sortDirection,
);
if (comparison !== 0) {
return comparison;
}
return left.index - right.index;
})
.map(({ row }) => row);
}, [result, sortColumn, sortDirection, visibleColumns]);
const currentPage = result?.page ?? 1;
const totalMatched = result?.totalMatched ?? result?.totalReturned ?? 0;
const totalPages = result
? Math.max(1, Math.ceil(totalMatched / Math.max(1, result.limit)))
: 1;
async function loadTables() {
setIsLoadingTables(true);
@@ -161,6 +200,9 @@ export function AdminDatabaseTablesPage({
search?: string;
filters?: string;
limit?: string;
page?: number;
sortColumn?: string;
sortDirection?: SortDirection;
} = {},
) {
const normalizedTableName = nextTableName.trim();
@@ -170,6 +212,8 @@ export function AdminDatabaseTablesPage({
const querySearch = options.search ?? search;
const queryFilters = options.filters ?? filters;
const queryLimit = options.limit ?? limit;
const querySortColumn = options.sortColumn ?? sortColumn;
const querySortDirection = options.sortDirection ?? sortDirection;
setIsLoadingRows(true);
setErrorMessage('');
try {
@@ -180,8 +224,20 @@ export function AdminDatabaseTablesPage({
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
page: options.page ?? 1,
sortColumn: querySortColumn || undefined,
sortDirection: querySortColumn ? querySortDirection : undefined,
},
);
appliedQueryRef.current = {
search: querySearch,
filters: queryFilters,
limit: queryLimit,
sortColumn: querySortColumn,
sortDirection: querySortDirection,
};
setSortColumn(querySortColumn);
setSortDirection(querySortDirection);
setResult(response);
setCopyMessage('');
} catch (error: unknown) {
@@ -193,10 +249,12 @@ export function AdminDatabaseTablesPage({
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void refreshRows();
void refreshRows(tableName, { page: 1 });
}
function handleTableChange(nextTableName: string) {
setSortColumn('');
setSortDirection('asc');
setTableName(nextTableName);
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
if (window.location.hash !== nextHash) {
@@ -208,19 +266,31 @@ export function AdminDatabaseTablesPage({
setSearch('');
setFilters('');
setLimit('100');
void refreshRows(tableName, { search: '', filters: '', limit: '100' });
setSortColumn('');
setSortDirection('asc');
void refreshRows(tableName, {
search: '',
filters: '',
limit: '100',
page: 1,
sortColumn: '',
sortDirection: 'asc',
});
}
function handlePageChange(page: number) {
void refreshRows(tableName, { ...appliedQueryRef.current, page });
}
function handleSortColumn(column: string) {
if (sortColumn === column) {
setSortDirection((currentDirection) =>
currentDirection === 'asc' ? 'desc' : 'asc',
);
return;
}
setSortColumn(column);
setSortDirection('asc');
const nextDirection =
sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc';
void refreshRows(tableName, {
...appliedQueryRef.current,
page: 1,
sortColumn: column,
sortDirection: nextDirection,
});
}
async function handleCopyDetailJson() {
@@ -242,7 +312,10 @@ export function AdminDatabaseTablesPage({
}
return (
<section className="admin-page admin-page-wide">
<section
ref={pageRef}
className="admin-page admin-page-wide admin-database-tables-page"
>
<div className="admin-page-heading">
<div>
<h2></h2>
@@ -262,7 +335,7 @@ export function AdminDatabaseTablesPage({
className="admin-primary-button"
disabled={!tableName || isLoadingRows}
type="button"
onClick={() => void refreshRows()}
onClick={() => void refreshRows(tableName, { page: 1 })}
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
@@ -302,7 +375,7 @@ export function AdminDatabaseTablesPage({
/>
</label>
<label className="admin-field admin-field-compact">
<span></span>
<span></span>
<input
inputMode="numeric"
value={limit}
@@ -342,12 +415,21 @@ export function AdminDatabaseTablesPage({
</div>
) : null}
{result?.scanLimitReached ? (
<section className="admin-panel admin-panel-warning" role="status">
{result.scanLimit} {' '}
{result.scannedCount}
</section>
) : null}
<section className="admin-panel">
<div className="admin-panel-heading">
<h3 title={resultTableHeader.description}>
{resultTableHeader.label}
</h3>
<span>{result?.totalReturned ?? 0} </span>
<span>
{totalMatched} {result?.totalReturned ?? 0}
</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-database-table">
@@ -371,6 +453,7 @@ export function AdminDatabaseTablesPage({
data-active={isSorted ? 'true' : 'false'}
title={description}
type="button"
disabled={isLoadingRows}
onClick={() => handleSortColumn(column)}
>
<span>{label}</span>
@@ -391,8 +474,8 @@ export function AdminDatabaseTablesPage({
</tr>
</thead>
<tbody>
{sortedRows.length ? (
sortedRows.map((row, rowIndex) => (
{result?.rows.length ? (
result.rows.map((row, rowIndex) => (
<tr
key={buildRowKey(row, rowIndex)}
data-clickable="true"
@@ -441,6 +524,41 @@ export function AdminDatabaseTablesPage({
</div>
</section>
{result ? (
<nav
className="admin-action-row admin-database-pagination"
aria-label="表查询分页"
>
<span className="admin-database-pagination-info">
{currentPage} / {totalPages} {totalMatched}
</span>
<div className="admin-action-row">
<button
className="admin-secondary-button"
aria-label="上一页"
disabled={isLoadingRows || currentPage <= 1}
title="上一页"
type="button"
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft size={17} aria-hidden="true" />
<span></span>
</button>
<button
className="admin-secondary-button"
aria-label="下一页"
disabled={isLoadingRows || currentPage >= totalPages}
title="下一页"
type="button"
onClick={() => handlePageChange(currentPage + 1)}
>
<span></span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
{detailRow ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
@@ -580,74 +698,6 @@ function getDatabaseTableColumnDescription(
return `原始字段名:${column}${description}。点击可按此列排序。`;
}
function compareTableCellValues(
leftValue: unknown,
rightValue: unknown,
sortDirection: SortDirection,
) {
const direction = sortDirection === 'asc' ? 1 : -1;
const left = normalizeTableCellSortValue(leftValue);
const right = normalizeTableCellSortValue(rightValue);
if (left.kind === 'empty' && right.kind === 'empty') {
return 0;
}
if (left.kind === 'empty') {
return 1;
}
if (right.kind === 'empty') {
return -1;
}
if (left.kind !== right.kind) {
return (
direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind))
);
}
let comparison = 0;
switch (left.kind) {
case 'number':
comparison = left.value - getSortableNumberValue(right);
break;
case 'boolean':
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
break;
case 'text':
comparison = tableSortCollator.compare(
left.value,
getSortableTextValue(right),
);
break;
}
return direction * comparison;
}
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return { kind: 'empty' };
}
if (typeof value === 'number' && Number.isFinite(value)) {
return { kind: 'number', value };
}
if (typeof value === 'boolean') {
return { kind: 'boolean', value };
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return { kind: 'empty' };
}
return { kind: 'text', value: trimmed };
}
return { kind: 'text', value: stringifyUnknownValue(value) };
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
const firstValue = Object.values(row.cells)[0];
return `${rowIndex}-${String(firstValue ?? '')}`;
@@ -797,27 +847,6 @@ function stringifyUnknownValue(value: unknown) {
}
}
function getSortKindOrder(kind: SortableTableCellValue['kind']): number {
switch (kind) {
case 'number':
return 0;
case 'boolean':
return 1;
case 'text':
return 2;
case 'empty':
return 3;
default:
return 3;
}
}
type SortableTableCellValue =
| { kind: 'empty' }
| { kind: 'number'; value: number }
| { kind: 'boolean'; value: boolean }
| { kind: 'text'; value: string };
interface DatabaseTableHeader {
name: string;
label: string;
@@ -830,11 +859,6 @@ interface FormattedTableCellValue {
fullText: string;
}
const tableSortCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base',
});
const databaseTableColumnLabelMap: Record<string, string> = {
id: 'ID',
table_name: '表名',
@@ -1489,35 +1513,3 @@ const databaseTableDescriptionMap: Record<string, string> = {
ai_result_reference: 'AI 结果引用表',
ai_task_event: 'AI 任务事件表',
};
function getSortableNumberValue(value: SortableTableCellValue) {
return isSortableNumberValue(value) ? value.value : 0;
}
function getSortableBooleanValue(value: SortableTableCellValue) {
return isSortableBooleanValue(value) ? value.value : false;
}
function getSortableTextValue(value: SortableTableCellValue) {
return isSortableTextValue(value)
? value.value
: stringifyUnknownValue(value);
}
function isSortableNumberValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, { kind: 'number' }> {
return value.kind === 'number';
}
function isSortableBooleanValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, { kind: 'boolean' }> {
return value.kind === 'boolean';
}
function isSortableTextValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, { kind: 'text' }> {
return value.kind === 'text';
}
+75
View File
@@ -1220,6 +1220,30 @@ button:disabled {
max-width: 112px;
}
.admin-database-tables-page {
padding-bottom: calc(var(--admin-database-pagination-height, 68px) + 16px);
}
.admin-database-pagination {
position: fixed;
right: 0;
bottom: 0;
left: 232px;
z-index: 18;
justify-content: space-between;
border-top: 1px solid #eaded2;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -8px 24px rgba(112, 57, 30, 0.08);
padding: 12px 24px calc(12px + env(safe-area-inset-bottom));
backdrop-filter: blur(10px);
}
.admin-database-pagination-info {
color: #755a49;
font-size: 13px;
font-weight: 700;
}
.admin-table-sort-button {
display: inline-flex;
align-items: center;
@@ -1588,6 +1612,20 @@ button:disabled {
backdrop-filter: blur(10px);
}
.admin-database-pagination {
right: 0;
bottom: var(--admin-bottom-nav-height, 64px);
left: 0;
padding: 10px 14px;
}
.admin-database-tables-page {
padding-bottom: calc(
var(--admin-bottom-nav-height, 64px) +
var(--admin-database-pagination-height, 58px) + 16px
);
}
.admin-bottom-nav-button {
display: grid;
gap: 4px;
@@ -1604,6 +1642,18 @@ button:disabled {
}
}
@media (max-width: 360px) {
.admin-database-pagination .admin-secondary-button {
width: 38px;
min-width: 38px;
padding: 0;
}
.admin-database-pagination .admin-secondary-button span {
display: none;
}
}
@media (max-width: 560px) {
.admin-login-panel,
.admin-panel {
@@ -1660,4 +1710,29 @@ button:disabled {
.admin-icon-button span {
display: none;
}
.admin-database-pagination,
.admin-database-pagination .admin-action-row {
flex-wrap: nowrap;
}
.admin-database-pagination {
gap: 8px;
padding: 10px;
}
.admin-database-pagination .admin-action-row {
gap: 6px;
}
.admin-database-pagination .admin-secondary-button {
gap: 4px;
min-height: 38px;
padding: 0 8px;
}
.admin-database-pagination-info {
font-size: 12px;
white-space: nowrap;
}
}
@@ -2097,6 +2097,14 @@
- 验证:执行 `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`
## 后台通用表查询不能先按每页条数截断再筛选
- 现象:后台“表查询”填写关键词或 JSON 条件后查不到确定存在的记录;把“条数”从 100 调到 500 只能偶尔缓解,而且页面没有继续翻页的入口。
- 原因:旧实现先执行 `SELECT * FROM <table> LIMIT <limit>`,再对这批行做内存过滤;目标记录不在首批结果时永远无法命中,同时响应没有页码、匹配总数或扫描上限状态。
- 处理:用户输入继续不进入通用 SQL。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选,先过滤,再按后端接收的列名 / 方向对完整候选集稳定排序,最后分页;`totalMatched``scannedCount``scanLimitReached` 都从同一份 SQL 结果计算。请求页码超过实际总页数时钳制到末页,零结果固定为第 1 页。响应统一返回 `page``totalMatched``scannedCount``scanLimit``scanLimitReached`,后台翻页栏固定在视口底部;达到扫描上限时明确提示结果可能不完整。32 MiB 是候选 SQL 响应体硬上限,宽表即使每页条数很小也可能整次拒绝,不返回部分结果。实时写入可能改变相邻请求之间的候选快照,精确审计使用专用业务查询。
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,覆盖第 101 行才命中、完整候选集排序后分页、哨兵截断、越界页码和响应体硬上限;前端测试覆盖下一页沿用已应用条件、后端排序参数 / 返回结果与扫描警告,并运行后台类型检查。
- 关联:`server-rs/crates/api-server/src/admin.rs``server-rs/crates/shared-contracts/src/admin.rs``apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx`
## 充值订单过期补偿不要放进外部生成 worker
- 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
@@ -627,6 +627,8 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
后台通用表查询已经处理 SpacetimeDB 无载荷枚举的 SATS 形态。新增后台表展示时,枚举列优先按表名和列名做业务映射,再落回通用解码。
后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / JSON 过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。
## Issue 与交接
- Issue tracker:自托管 Gitea。
File diff suppressed because it is too large Load Diff
@@ -522,8 +522,11 @@ pub struct AdminDatabaseTableListResponse {
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableRowsQuery {
pub limit: Option<u32>,
pub page: Option<u32>,
pub search: Option<String>,
pub filters: Option<String>,
pub sort_column: Option<String>,
pub sort_direction: Option<String>,
}
// 后台通用表查询响应,cells 使用列名映射,raw 保留原始行便于详情排障。
@@ -534,7 +537,12 @@ pub struct AdminDatabaseTableRowsResponse {
pub columns: Vec<String>,
pub rows: Vec<AdminDatabaseTableRowPayload>,
pub total_returned: usize,
pub total_matched: usize,
pub limit: u32,
pub page: u32,
pub scanned_count: usize,
pub scan_limit: u32,
pub scan_limit_reached: bool,
}
// 单行查询结果,值统一用 JSON 承载以兼容不同表字段类型。