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:
+154
-10
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user