feat(page-data): complete Phase 4-5, ops UI, and publish integration

Add visitor roles, row-level scope, owner ops APIs, MySQL policy index,
Turnstile captcha, browser client SDK, publish-panel dataset binding,
acceptance tests, and usage documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-08 14:52:49 +08:00
parent 8d01553469
commit 6b0c633a75
40 changed files with 4056 additions and 60 deletions
+154 -10
View File
@@ -408,7 +408,44 @@ export function createUserDataSpaceService(options = {}) {
return keys.map((key) => assertSafeSqlIdentifier(key, '更新字段'));
}
function readRowsForDataset(dataset, { limit, offset, orderBy, orderDir } = {}) {
function buildDatasetWhereClause(table, { includeDeleted = false, rowScope = null } = {}) {
const parts = [];
if (!includeDeleted && tableHasColumn(table, 'deleted_at')) {
parts.push('deleted_at IS NULL');
}
if (rowScope?.whereClause) {
parts.push(rowScope.whereClause);
}
return parts.length ? `WHERE ${parts.join(' AND ')}` : '';
}
function readRowByIdForDataset(dataset, rowId, { rowScope = null, includeDeleted = false } = {}) {
assertDatasetAction(dataset, 'read');
const table = assertSafeSqlIdentifier(dataset.table, '表名');
const id = Number(rowId);
if (!Number.isFinite(id) || id <= 0) {
throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' });
}
const columns = resolveReadColumns(dataset);
const parts = [`id = ${id}`];
if (!includeDeleted && tableHasColumn(table, 'deleted_at')) {
parts.push('deleted_at IS NULL');
}
if (rowScope?.whereClause) {
parts.push(rowScope.whereClause);
}
const sql = `SELECT ${columns.map((column) => `"${column}"`).join(', ')}
FROM "${table}"
WHERE ${parts.join(' AND ')}
LIMIT 1`;
const rows = runJsonQuery(sql);
if (!rows.length) {
throw Object.assign(new Error('无权访问该行或行不存在'), { code: 'row_not_allowed' });
}
return rows[0];
}
function readRowsForDataset(dataset, { limit, offset, orderBy, orderDir, includeDeleted = false, rowScope = null } = {}) {
assertDatasetAction(dataset, 'read');
const columns = resolveReadColumns(dataset);
const table = assertSafeSqlIdentifier(dataset.table, '表名');
@@ -418,7 +455,7 @@ export function createUserDataSpaceService(options = {}) {
);
const safeLimit = Math.min(Math.max(1, Number(limit ?? maxLimit)), maxLimit);
const safeOffset = Math.max(0, Number(offset ?? 0));
const where = tableHasColumn(table, 'deleted_at') ? 'WHERE deleted_at IS NULL' : '';
const where = buildDatasetWhereClause(table, { includeDeleted, rowScope });
let orderClause = '';
if (orderBy) {
const column = assertSafeSqlIdentifier(orderBy, '排序字段');
@@ -447,10 +484,10 @@ export function createUserDataSpaceService(options = {}) {
return readRowsForDataset(dataset, options);
}
function getStatsForDataset(dataset) {
function getStatsForDataset(dataset, { rowScope = null } = {}) {
assertDatasetAction(dataset, 'read');
const table = assertSafeSqlIdentifier(dataset.table, '表名');
const where = tableHasColumn(table, 'deleted_at') ? 'WHERE deleted_at IS NULL' : '';
const where = buildDatasetWhereClause(table, { rowScope });
const total = sqliteScalar(`SELECT COUNT(*) FROM "${table}" ${where};`);
return { dataset: { name: dataset.name, table: dataset.table }, total };
}
@@ -485,7 +522,23 @@ export function createUserDataSpaceService(options = {}) {
if (tableHasColumn(table, 'page_data_session_id') && values.page_data_session_id == null && meta.pageDataSessionId) {
values.page_data_session_id = meta.pageDataSessionId;
}
const insertColumns = columns.filter((column) => values[column] !== undefined);
if (
meta.rowScope?.ownerColumn &&
tableHasColumn(table, meta.rowScope.ownerColumn) &&
values[meta.rowScope.ownerColumn] == null &&
meta.rowScope.visitorUserId
) {
values[meta.rowScope.ownerColumn] = meta.rowScope.visitorUserId;
}
let insertColumns = columns.filter((column) => values[column] !== undefined);
if (
meta.rowScope?.ownerColumn &&
tableHasColumn(table, meta.rowScope.ownerColumn) &&
values[meta.rowScope.ownerColumn] != null &&
!insertColumns.includes(meta.rowScope.ownerColumn)
) {
insertColumns.push(meta.rowScope.ownerColumn);
}
if (!insertColumns.length) {
throw Object.assign(new Error('提交数据不能为空'), { code: 'invalid_payload' });
}
@@ -495,7 +548,12 @@ export function createUserDataSpaceService(options = {}) {
const id = sqliteScalar('SELECT last_insert_rowid();');
let row = { id };
if (dataset.actions.includes('read')) {
const readBack = readRowsForDataset(dataset, { limit: 1, orderBy: 'id', orderDir: 'desc' });
const readBack = readRowsForDataset(dataset, {
limit: 1,
orderBy: 'id',
orderDir: 'desc',
rowScope: meta.rowScope?.whereClause ? meta.rowScope : null,
});
row = readBack.rows.find((item) => Number(item.id) === Number(id)) ?? readBack.rows[0] ?? row;
} else {
for (const column of insertColumns) {
@@ -535,13 +593,13 @@ export function createUserDataSpaceService(options = {}) {
if (tableHasColumn(table, 'updated_by_label') && meta.updatedByLabel && !columns.includes('updated_by_label')) {
assignments.push(`"updated_by_label" = ${sqlLiteral(meta.updatedByLabel)}`);
}
const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : '';
const deletedFilter = tableHasColumn(table, 'deleted_at') ? ' AND deleted_at IS NULL' : '';
const sql = `UPDATE "${table}"
SET ${assignments.join(', ')}
WHERE id = ${id}${deletedFilter};`;
WHERE id = ${id}${deletedFilter}${scopeClause};`;
await executeSql(sql);
const readBack = readRowsForDataset(dataset, { limit: 1, orderBy: 'id', orderDir: 'desc' });
const row = readBack.rows.find((item) => Number(item.id) === id) ?? { id };
const row = readRowByIdForDataset(dataset, id, { rowScope: meta.rowScope ?? null });
return { dataset, row };
}
@@ -559,13 +617,96 @@ export function createUserDataSpaceService(options = {}) {
if (tableHasColumn(table, 'deleted_by')) {
assignments.push(`deleted_by = ${sqlLiteral(meta.deletedBy ?? meta.updatedByLabel ?? 'public')}`);
}
const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : '';
const sql = `UPDATE "${table}"
SET ${assignments.join(', ')}
WHERE id = ${id} AND deleted_at IS NULL;`;
WHERE id = ${id} AND deleted_at IS NULL${scopeClause};`;
await executeSql(sql);
const verifyParts = [`id = ${id}`];
if (meta.rowScope?.whereClause) {
verifyParts.push(meta.rowScope.whereClause);
}
const deletedRow = runJsonQuery(
`SELECT id, deleted_at FROM "${table}" WHERE ${verifyParts.join(' AND ')} LIMIT 1`,
);
if (!deletedRow.length || !deletedRow[0].deleted_at) {
throw Object.assign(new Error('无权删除该行或行不存在'), { code: 'row_not_allowed' });
}
return { dataset, id, deleted: true };
}
async function restoreSoftDeletedRowForDataset(dataset, rowId) {
const table = assertSafeSqlIdentifier(dataset.table, '表名');
const id = Number(rowId);
if (!Number.isFinite(id) || id <= 0) {
throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' });
}
if (!tableHasColumn(table, 'deleted_at')) {
throw Object.assign(new Error('目标表不支持软删除恢复'), { code: 'restore_unsupported' });
}
const assignments = ['deleted_at = NULL'];
if (tableHasColumn(table, 'deleted_by')) {
assignments.push('deleted_by = NULL');
}
const sql = `UPDATE "${table}"
SET ${assignments.join(', ')}
WHERE id = ${id} AND deleted_at IS NOT NULL;`;
await executeSql(sql);
const readBack = readRowsForDataset(dataset, { limit: 1, orderBy: 'id', orderDir: 'desc', includeDeleted: true });
const row = readBack.rows.find((item) => Number(item.id) === id) ?? { id };
return { dataset, row, restored: true };
}
async function restoreSoftDeletedRow(datasetName, rowId) {
const dataset = getDataset(datasetName);
if (!dataset) {
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
}
return restoreSoftDeletedRowForDataset(dataset, rowId);
}
function csvEscape(value) {
const text = value == null ? '' : String(value);
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
function exportRowsAsCsv(rows, columns) {
const header = columns.join(',');
const body = rows.map((row) => columns.map((column) => csvEscape(row[column])).join(','));
return `${header}\n${body.join('\n')}\n`;
}
function exportDatasetRows(datasetName, { format = 'json', includeDeleted = false, limit = 1000 } = {}) {
const dataset = getDataset(datasetName);
if (!dataset) {
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
}
assertDatasetAction(dataset, 'read');
const exportLimit = Math.min(Math.max(1, Number(limit ?? 1000)), 5000);
const result = readRowsForDataset(dataset, {
limit: exportLimit,
offset: 0,
includeDeleted: Boolean(includeDeleted),
});
const normalizedFormat = String(format ?? 'json').toLowerCase();
if (normalizedFormat === 'csv') {
const columns = resolveReadColumns(dataset);
return {
dataset: dataset.name,
format: 'csv',
content: exportRowsAsCsv(result.rows, columns),
rowCount: result.rows.length,
};
}
return {
dataset: dataset.name,
format: 'json',
rows: result.rows,
rowCount: result.rows.length,
};
}
function getSchemaForDataset(dataset) {
const tableColumns = listTableColumns(dataset.table);
return {
@@ -612,6 +753,9 @@ export function createUserDataSpaceService(options = {}) {
insertRowForDataset,
updateRowForDataset,
softDeleteRowForDataset,
restoreSoftDeletedRowForDataset,
restoreSoftDeletedRow,
exportDatasetRows,
getDatasetSchema,
getSchemaForDataset,
};