fix(page-data): isolate owner API from public pages
This commit is contained in:
+18
-16
@@ -20,25 +20,27 @@
|
||||
|
||||
可在发布面板或策略中逐项覆盖。
|
||||
|
||||
## Owner API(需登录)
|
||||
## Owner API(需登录,仅后台管理界面)
|
||||
|
||||
`/api/page-data/*` 已移除,不可被公开 HTML 调用;旧页面请求会收到 `410 legacy_page_data_api_removed`。
|
||||
|
||||
```text
|
||||
GET /api/page-data # 列出已注册 dataset
|
||||
GET /api/page-data/:dataset
|
||||
POST /api/page-data/:dataset/rows
|
||||
PATCH /api/page-data/:dataset/rows/:id
|
||||
DELETE /api/page-data/:dataset/rows/:id # soft delete
|
||||
POST /api/page-data/:dataset/rows/:id/restore
|
||||
GET /api/admin/page-data # 列出已注册 dataset
|
||||
GET /api/admin/page-data/:dataset
|
||||
POST /api/admin/page-data/:dataset/rows
|
||||
PATCH /api/admin/page-data/:dataset/rows/:id
|
||||
DELETE /api/admin/page-data/:dataset/rows/:id # soft delete
|
||||
POST /api/admin/page-data/:dataset/rows/:id/restore
|
||||
|
||||
GET /api/page-data/policies
|
||||
GET /api/page-data/policies/:pageId
|
||||
PUT /api/page-data/policies/:pageId
|
||||
POST /api/page-data/policies/:pageId/apply-publish
|
||||
GET /api/page-data/policies/:pageId/ops
|
||||
GET /api/page-data/policies/:pageId/logs
|
||||
POST /api/page-data/policies/:pageId/tokens/revoke
|
||||
POST /api/page-data/policies/:pageId/password/reset
|
||||
POST /api/page-data/policies/:pageId/datasets/:dataset/close
|
||||
GET /api/admin/page-data/policies
|
||||
GET /api/admin/page-data/policies/:pageId
|
||||
PUT /api/admin/page-data/policies/:pageId
|
||||
POST /api/admin/page-data/policies/:pageId/apply-publish
|
||||
GET /api/admin/page-data/policies/:pageId/ops
|
||||
GET /api/admin/page-data/policies/:pageId/logs
|
||||
POST /api/admin/page-data/policies/:pageId/tokens/revoke
|
||||
POST /api/admin/page-data/policies/:pageId/password/reset
|
||||
POST /api/admin/page-data/policies/:pageId/datasets/:dataset/close
|
||||
```
|
||||
|
||||
## 公开 API(无需平台登录)
|
||||
|
||||
@@ -96,6 +96,13 @@ test('inferPageDataBindAccessMode chooses password for admin html', () => {
|
||||
'password',
|
||||
);
|
||||
assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public');
|
||||
assert.equal(
|
||||
inferPageDataBindAccessMode(
|
||||
'public/private-tracker.html',
|
||||
'<script>const client = MindSpacePageData.createClient(); client.authenticate(password); client.listRows(\'entries\'); client.insertRow(\'entries\', {});</script>',
|
||||
),
|
||||
'password',
|
||||
);
|
||||
});
|
||||
|
||||
test('evaluatePageDataFinishGuard detects unbound page data html', () => {
|
||||
|
||||
@@ -24,7 +24,9 @@ export function normalizePolicyDataset(name, raw) {
|
||||
insert: Boolean(raw.insert),
|
||||
update: Boolean(raw.update),
|
||||
softDelete: Boolean(raw.softDelete ?? raw.soft_delete),
|
||||
hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete),
|
||||
// `delete` is the page-facing spelling for an intentional permanent PG
|
||||
// delete; retain hardDelete for existing policies.
|
||||
hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete ?? raw.delete),
|
||||
columns: normalizedColumns,
|
||||
limits:
|
||||
raw.limits && typeof raw.limits === 'object'
|
||||
|
||||
@@ -121,19 +121,19 @@ test('acceptance: agent dataset registry + owner read/insert via Page Data API',
|
||||
await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const datasets = await request(app, 'GET', '/api/page-data', {
|
||||
const datasets = await request(app, 'GET', '/api/admin/page-data', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(datasets.status, 200);
|
||||
assert.equal(datasets.body.data.datasets[0].name, 'leads');
|
||||
|
||||
const inserted = await request(app, 'POST', '/api/page-data/leads/rows', {
|
||||
const inserted = await request(app, 'POST', '/api/admin/page-data/leads/rows', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: { name: 'Owner 线索', note: '内部' },
|
||||
});
|
||||
assert.equal(inserted.status, 201);
|
||||
|
||||
const listed = await request(app, 'GET', '/api/page-data/leads?limit=10', {
|
||||
const listed = await request(app, 'GET', '/api/admin/page-data/leads?limit=10', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(listed.status, 200);
|
||||
@@ -237,7 +237,7 @@ test('acceptance: apply-publish creates policy for published page', async () =>
|
||||
await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, {
|
||||
const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
datasetName: 'leads',
|
||||
|
||||
@@ -41,12 +41,21 @@ export function detectPageDataDatasetUsageFromHtml(html) {
|
||||
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
|
||||
remember(match[1], { read: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.deleteRow\(\s*['"]([^'"]+)['"]/g)) {
|
||||
// The browser client's deleteRow endpoint intentionally falls back to a
|
||||
// soft delete unless a policy explicitly grants hard_delete. A page using
|
||||
// this API must therefore only require the safe, default capability.
|
||||
remember(match[1], { softDelete: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
remember(constants.get(match[1]) ?? match[1], { insert: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
remember(constants.get(match[1]) ?? match[1], { read: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.deleteRow\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
remember(constants.get(match[1]) ?? match[1], { softDelete: true });
|
||||
}
|
||||
|
||||
return datasets;
|
||||
}
|
||||
@@ -55,7 +64,16 @@ export function inferPageDataBindAccessMode(relativePath, html) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const hasRead = [...usage.values()].some((item) => item.read);
|
||||
const hasInsert = [...usage.values()].some((item) => item.insert);
|
||||
if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
|
||||
// A page that exchanges a password for a Page Data token is intentionally
|
||||
// protected even when it both reads and writes its dataset (for example, a
|
||||
// personal tracker). Do not silently re-bind it as public merely because it
|
||||
// is not named "-admin.html".
|
||||
const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(String(html ?? ''));
|
||||
if (
|
||||
/-admin\.html$/i.test(String(relativePath ?? '')) ||
|
||||
(hasRead && !hasInsert) ||
|
||||
usesServerAuthentication
|
||||
) {
|
||||
return 'password';
|
||||
}
|
||||
return 'public';
|
||||
@@ -122,6 +140,10 @@ export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets
|
||||
entry.columns.read = registered.columns?.read ?? [];
|
||||
}
|
||||
}
|
||||
if (perms.softDelete) {
|
||||
entry.softDelete = true;
|
||||
entry.columns.soft_delete = registered.columns?.soft_delete ?? ['id'];
|
||||
}
|
||||
|
||||
datasets[name] = entry;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,24 @@ test('htmlUsesForbiddenLegacyPageDataApi detects /api/page-data passthrough', ()
|
||||
assert.equal(detectPageDataDatasetUsageFromHtml(html).size, 0);
|
||||
});
|
||||
|
||||
test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => {
|
||||
test('detectPageDataDatasetUsageFromHtml treats browser deleteRow as a soft delete', () => {
|
||||
const html = `
|
||||
await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' });
|
||||
await c.listRows('tkmind_exp_survey', { limit: 10 });
|
||||
await c.deleteRow('tkmind_exp_survey', 42);
|
||||
`;
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
assert.equal(usage.size, 1);
|
||||
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true });
|
||||
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true, softDelete: true });
|
||||
});
|
||||
|
||||
test('buildPageDataPolicyDatasetsFromRegistry grants registered soft delete when HTML uses deleteRow', () => {
|
||||
const datasets = buildPageDataPolicyDatasetsFromRegistry({
|
||||
html: `const DATASET = 'mood_notes'; await c.deleteRow(DATASET, 1);`,
|
||||
registryDatasets: [{ name: 'mood_notes', columns: { soft_delete: ['id'] } }],
|
||||
});
|
||||
assert.equal(datasets.mood_notes.softDelete, true);
|
||||
assert.deepEqual(datasets.mood_notes.columns.soft_delete, ['id']);
|
||||
});
|
||||
|
||||
test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => {
|
||||
|
||||
@@ -133,14 +133,14 @@ test('integration: owner private API and public insert coexist without breaking
|
||||
const service = await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const ownerInsert = await request(app, 'POST', '/api/page-data/entries/rows', {
|
||||
const ownerInsert = await request(app, 'POST', '/api/admin/page-data/entries/rows', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: { title: 'owner 写入' },
|
||||
});
|
||||
assert.equal(ownerInsert.status, 201);
|
||||
assert.equal(ownerInsert.body.data.row.title, 'owner 写入');
|
||||
|
||||
const ownerList = await request(app, 'GET', '/api/page-data/entries?limit=10', {
|
||||
const ownerList = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(ownerList.status, 200);
|
||||
@@ -157,7 +157,7 @@ test('integration: owner private API and public insert coexist without breaking
|
||||
},
|
||||
},
|
||||
};
|
||||
const policyWrite = await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
const policyWrite = await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: policy,
|
||||
});
|
||||
@@ -174,7 +174,7 @@ test('integration: owner private API and public insert coexist without breaking
|
||||
assert.equal(publicReadDenied.status, 403);
|
||||
assert.equal(publicReadDenied.body.error.code, 'action_not_allowed');
|
||||
|
||||
const ownerListAfterPublic = await request(app, 'GET', '/api/page-data/entries?limit=10', {
|
||||
const ownerListAfterPublic = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(ownerListAfterPublic.status, 200);
|
||||
@@ -188,7 +188,7 @@ test('integration: public insert rejects SQL injection style payload keys', asyn
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-'));
|
||||
await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
pageId: PAGE_ID,
|
||||
@@ -252,7 +252,7 @@ test('integration: password publication flow still works for read after data-aut
|
||||
const app = express();
|
||||
app.use('/api', api);
|
||||
|
||||
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
pageId: PAGE_ID,
|
||||
@@ -284,7 +284,7 @@ test('integration: public insert writes operation logs for owner review', async
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-logs-int-'));
|
||||
await setupPublicWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
pageId: PAGE_ID,
|
||||
@@ -296,7 +296,7 @@ test('integration: public insert writes operation logs for owner review', async
|
||||
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
|
||||
body: { name: '日志测试', phone: '13800000000' },
|
||||
});
|
||||
const logs = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/logs`, {
|
||||
const logs = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/logs`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(logs.status, 200);
|
||||
@@ -313,7 +313,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn
|
||||
phone: '13800000001',
|
||||
});
|
||||
const app = buildApp(workspaceRoot);
|
||||
const exported = await request(app, 'GET', '/api/page-data/signups/export?format=json', {
|
||||
const exported = await request(app, 'GET', '/api/admin/page-data/signups/export?format=json', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(exported.status, 200);
|
||||
@@ -322,7 +322,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn
|
||||
await ownerService.softDeleteRowForDataset(ownerService.getDataset('signups'), inserted.row.id, {
|
||||
deletedBy: 'test',
|
||||
});
|
||||
const restored = await request(app, 'POST', `/api/page-data/signups/rows/${inserted.row.id}/restore`, {
|
||||
const restored = await request(app, 'POST', `/api/admin/page-data/signups/rows/${inserted.row.id}/restore`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(restored.status, 200);
|
||||
@@ -334,7 +334,7 @@ test('integration: apply-publish route binds dataset after publication', async (
|
||||
await setupPublicWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, {
|
||||
const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
datasetName: 'signups',
|
||||
|
||||
@@ -142,7 +142,7 @@ test('ops overview returns datasets stats logs and active sessions', async () =>
|
||||
headers: { 'x-page-data-token': (await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { body: { password: OLD_PASSWORD } })).body.data.token },
|
||||
});
|
||||
|
||||
const overview = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/ops`, {
|
||||
const overview = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/ops`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(overview.status, 200);
|
||||
@@ -171,7 +171,7 @@ test('owner can revoke all tokens and reset password', async () => {
|
||||
let updatedPasswordHash = null;
|
||||
const app = buildApp(workspaceRoot, createPool({ onUpdate: ([hash]) => { updatedPasswordHash = hash; } }), sessionStore);
|
||||
|
||||
const revoked = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/tokens/revoke`, {
|
||||
const revoked = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/tokens/revoke`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: { revokeAll: true },
|
||||
});
|
||||
@@ -180,7 +180,7 @@ test('owner can revoke all tokens and reset password', async () => {
|
||||
assert.equal(sessionStore.verify(auth1.token), null);
|
||||
assert.equal(sessionStore.verify(auth2.token), null);
|
||||
|
||||
const reset = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/password/reset`, {
|
||||
const reset = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/password/reset`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: { password: NEW_PASSWORD },
|
||||
});
|
||||
@@ -210,7 +210,7 @@ test('owner can close dataset and public insert is denied afterwards', async ()
|
||||
body: { password: OLD_PASSWORD },
|
||||
});
|
||||
|
||||
const closed = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/datasets/signups/close`, {
|
||||
const closed = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/datasets/signups/close`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(closed.status, 200);
|
||||
|
||||
@@ -501,18 +501,21 @@ export function createPageDataPublicService(deps = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function softDeleteRow(pageId, datasetName, rowId, req) {
|
||||
async function deleteRow(pageId, datasetName, rowId, req) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
const policy = await loadPolicy(publication);
|
||||
const deleteAction = policyAllowsAction(policy, datasetName, 'hard_delete')
|
||||
? 'hard_delete'
|
||||
: 'soft_delete';
|
||||
const access = resolveAccessContext({
|
||||
publication,
|
||||
policy,
|
||||
req,
|
||||
action: 'soft_delete',
|
||||
action: deleteAction,
|
||||
datasetName,
|
||||
});
|
||||
enforceRateLimit(req, publication, 'soft_delete');
|
||||
enforceRateLimit(req, publication, deleteAction);
|
||||
const { ownerService, effective, policyDataset } = await resolveEffectiveDataset(
|
||||
publication.user_id,
|
||||
policy,
|
||||
@@ -531,13 +534,15 @@ export function createPageDataPublicService(deps = {}) {
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const result = await ownerService.softDeleteRowForDataset(effective, rowId, {
|
||||
const result = deleteAction === 'hard_delete'
|
||||
? await ownerService.hardDeleteRowForDataset(effective, rowId, { rowScope })
|
||||
: await ownerService.softDeleteRowForDataset(effective, rowId, {
|
||||
deletedBy: access.session?.sessionId ?? 'public',
|
||||
rowScope,
|
||||
});
|
||||
recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, {
|
||||
datasetName,
|
||||
action: 'soft_delete',
|
||||
action: deleteAction,
|
||||
rowId,
|
||||
req,
|
||||
session: access.session,
|
||||
@@ -781,7 +786,8 @@ export function createPageDataPublicService(deps = {}) {
|
||||
getStats,
|
||||
insertRow,
|
||||
updateRow,
|
||||
softDeleteRow,
|
||||
deleteRow,
|
||||
softDeleteRow: deleteRow,
|
||||
getOwnerPolicy,
|
||||
saveOwnerPolicy,
|
||||
saveOwnerPolicyFromPublish,
|
||||
|
||||
+42
-19
@@ -1,6 +1,16 @@
|
||||
import { stripPageDataMetaFields } from './page-data-captcha.mjs';
|
||||
import { mapPageDataPublicError } from './page-data-public-service.mjs';
|
||||
|
||||
// Owner/admin APIs intentionally use a separate namespace from APIs callable
|
||||
// by published HTML. A generated public page must only use
|
||||
// `/api/public/pages/:pageId/data/...`; keeping `/api/page-data` live made it
|
||||
// too easy for an LLM-generated page to accidentally target owner APIs.
|
||||
const OWNER_PAGE_DATA_API_PREFIX = '/admin/page-data';
|
||||
|
||||
export function isLegacyPageDataApiPath(pathname) {
|
||||
return /^\/page-data(?:\/|$)/.test(String(pathname ?? ''));
|
||||
}
|
||||
|
||||
function requireUser(req, res, sendError) {
|
||||
if (!req.currentUser?.id) {
|
||||
sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||||
@@ -31,7 +41,7 @@ function handlePublicError(res, req, sendError, error) {
|
||||
export function attachPageDataRoutes(api, deps) {
|
||||
const { sendData, sendError, getPageDataService, getPageDataPublicService } = deps;
|
||||
|
||||
api.get('/page-data', async (req, res) => {
|
||||
api.get(OWNER_PAGE_DATA_API_PREFIX, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -45,7 +55,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/policies', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -58,7 +68,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/policies/:pageId', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -72,7 +82,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/policies/:pageId/apply-publish', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/apply-publish`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -90,7 +100,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.put('/page-data/policies/:pageId', async (req, res) => {
|
||||
api.put(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -103,7 +113,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/policies/:pageId/logs', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/logs`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -120,7 +130,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/policies/:pageId/ops', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/ops`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -133,7 +143,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/policies/:pageId/tokens/revoke', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/tokens/revoke`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -149,7 +159,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/policies/:pageId/password/reset', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/password/reset`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -164,7 +174,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/policies/:pageId/datasets/:dataset/close', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/datasets/:dataset/close`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
@@ -177,7 +187,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset/export', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/export`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -203,7 +213,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -228,7 +238,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset/schema', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/schema`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -242,7 +252,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset/stats', async (req, res) => {
|
||||
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/stats`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -256,7 +266,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/:dataset/rows', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -272,7 +282,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/:dataset/rows/:rowId/restore', async (req, res) => {
|
||||
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId/restore`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -286,7 +296,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||
api.patch(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -302,7 +312,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||
api.delete(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
@@ -408,7 +418,7 @@ export function attachPageDataRoutes(api, deps) {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const result = await publicService.softDeleteRow(
|
||||
const result = await publicService.deleteRow(
|
||||
req.params.pageId,
|
||||
req.params.dataset,
|
||||
req.params.rowId,
|
||||
@@ -419,4 +429,17 @@ export function attachPageDataRoutes(api, deps) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Do not retain a compatibility alias. It would keep the forbidden API
|
||||
// usable from generated pages and turn a future prompt regression into a
|
||||
// production data-access bug. Owner UI has moved to /admin/page-data.
|
||||
api.use('/page-data', (req, res) =>
|
||||
sendError(
|
||||
res,
|
||||
req,
|
||||
410,
|
||||
'legacy_page_data_api_removed',
|
||||
'旧版 Page Data API 已移除;公开页面请使用 /api/public/pages/:pageId/data,后台请使用 /api/admin/page-data。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+25
-11
@@ -4,7 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import express from 'express';
|
||||
import { attachPageDataRoutes } from './page-data-routes.mjs';
|
||||
import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs';
|
||||
import { createPageDataService } from './page-data-service.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
|
||||
@@ -82,21 +82,21 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a
|
||||
user: { id: 'user-1', workspaceRoot },
|
||||
});
|
||||
|
||||
const inserted = await requestJson(app, 'POST', '/api/page-data/feedback/rows', {
|
||||
const inserted = await requestJson(app, 'POST', '/api/admin/page-data/feedback/rows', {
|
||||
message: '很好用',
|
||||
});
|
||||
assert.equal(inserted.status, 201);
|
||||
assert.equal(inserted.body.data.row.message, '很好用');
|
||||
|
||||
const listed = await requestJson(app, 'GET', '/api/page-data/feedback?limit=10');
|
||||
const listed = await requestJson(app, 'GET', '/api/admin/page-data/feedback?limit=10');
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.body.data.rows.length, 1);
|
||||
|
||||
const schema = await requestJson(app, 'GET', '/api/page-data/feedback/schema');
|
||||
const schema = await requestJson(app, 'GET', '/api/admin/page-data/feedback/schema');
|
||||
assert.equal(schema.status, 200);
|
||||
assert.equal(schema.body.data.dataset.name, 'feedback');
|
||||
|
||||
const stats = await requestJson(app, 'GET', '/api/page-data/feedback/stats');
|
||||
const stats = await requestJson(app, 'GET', '/api/admin/page-data/feedback/stats');
|
||||
assert.equal(stats.status, 200);
|
||||
assert.equal(stats.body.data.total, 1);
|
||||
});
|
||||
@@ -130,21 +130,21 @@ test('page data routes allow owner update and soft delete', async () => {
|
||||
user: { id: 'user-1', workspaceRoot },
|
||||
});
|
||||
|
||||
const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' });
|
||||
const inserted = await requestJson(app, 'POST', '/api/admin/page-data/tasks/rows', { title: '待办' });
|
||||
assert.equal(inserted.status, 201);
|
||||
const rowId = inserted.body.data.row.id;
|
||||
|
||||
const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, {
|
||||
const updated = await requestJson(app, 'PATCH', `/api/admin/page-data/tasks/rows/${rowId}`, {
|
||||
status: 'done',
|
||||
});
|
||||
assert.equal(updated.status, 200);
|
||||
assert.equal(updated.body.data.row.status, 'done');
|
||||
|
||||
const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`);
|
||||
const deleted = await requestJson(app, 'DELETE', `/api/admin/page-data/tasks/rows/${rowId}`);
|
||||
assert.equal(deleted.status, 200);
|
||||
assert.equal(deleted.body.data.deleted, true);
|
||||
|
||||
const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10');
|
||||
const listed = await requestJson(app, 'GET', '/api/admin/page-data/tasks?limit=10');
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.body.data.rows.length, 0);
|
||||
});
|
||||
@@ -168,11 +168,25 @@ test('page data routes reject unauthorized dataset action', async () => {
|
||||
user: { id: 'user-1', workspaceRoot },
|
||||
});
|
||||
|
||||
const denied = await requestJson(app, 'GET', '/api/page-data/hidden');
|
||||
const denied = await requestJson(app, 'GET', '/api/admin/page-data/hidden');
|
||||
assert.equal(denied.status, 403);
|
||||
assert.equal(denied.body.error.code, 'action_not_allowed');
|
||||
});
|
||||
|
||||
test('legacy page data API is permanently unavailable', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-legacy-'));
|
||||
const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot } });
|
||||
|
||||
const blocked = await requestJson(app, 'POST', '/api/page-data/notes/rows', { content: '旧页面' });
|
||||
assert.equal(blocked.status, 410);
|
||||
assert.equal(blocked.body.error.code, 'legacy_page_data_api_removed');
|
||||
});
|
||||
|
||||
test('legacy API path detector does not include the admin namespace', () => {
|
||||
assert.equal(isLegacyPageDataApiPath('/page-data/notes/rows'), true);
|
||||
assert.equal(isLegacyPageDataApiPath('/admin/page-data/notes/rows'), false);
|
||||
});
|
||||
|
||||
test('page data routes require login', async () => {
|
||||
const api = express.Router();
|
||||
attachPageDataRoutes(api, {
|
||||
@@ -180,7 +194,7 @@ test('page data routes require login', async () => {
|
||||
sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }),
|
||||
getPageDataService: () => createPageDataService({ resolveWorkspaceRoot: async () => '/tmp' }),
|
||||
});
|
||||
const handler = api.stack.find((layer) => layer.route?.path === '/page-data/:dataset')?.route.stack[0].handle;
|
||||
const handler = api.stack.find((layer) => layer.route?.path === '/admin/page-data/:dataset')?.route.stack[0].handle;
|
||||
const res = createResponseRecorder();
|
||||
await handler({ params: { dataset: 'demo' } }, res);
|
||||
assert.equal(res.statusCode, 401);
|
||||
|
||||
@@ -152,6 +152,13 @@ function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) {
|
||||
action: 'read',
|
||||
});
|
||||
}
|
||||
if (requiredUsage.softDelete && !dataset.actions.includes('soft_delete')) {
|
||||
throw Object.assign(new Error(`dataset「${dataset.name}」未开放软删除`), {
|
||||
code: 'dataset_action_not_registered',
|
||||
datasetName: dataset.name,
|
||||
action: 'soft_delete',
|
||||
});
|
||||
}
|
||||
const actualColumns = userDataSpace.listTableColumns(dataset.table);
|
||||
if (!actualColumns.length) {
|
||||
throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), {
|
||||
|
||||
+7
-3
@@ -203,7 +203,7 @@ import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-conf
|
||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||
import { createExperienceService } from './experience-service.mjs';
|
||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||
import { attachPageDataRoutes } from './page-data-routes.mjs';
|
||||
import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs';
|
||||
import { createPageDataService } from './page-data-service.mjs';
|
||||
import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
|
||||
import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
|
||||
@@ -2035,10 +2035,14 @@ api.use(async (req, res, next) => {
|
||||
|
||||
const plazaPublic = isPlazaPublicRead(req.path, req.method);
|
||||
const pageDataPublic = isPageDataPublicPath(req.path, req.method);
|
||||
// Let the retired namespace reach its explicit 410 handler below. Without
|
||||
// this exception the outer auth middleware turns an old public HTML request
|
||||
// into a misleading 401/403 before the legacy-endpoint block can run.
|
||||
const legacyPageDataApi = isLegacyPageDataApiPath(req.path);
|
||||
|
||||
if (userAuth && tkmindProxy) {
|
||||
if (req.userSessionError) {
|
||||
if (plazaPublic || pageDataPublic) return next();
|
||||
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
|
||||
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
|
||||
}
|
||||
try {
|
||||
@@ -2046,7 +2050,7 @@ api.use(async (req, res, next) => {
|
||||
const me = await userAuth.getMe(req.userToken);
|
||||
if (me) req.currentUser = me;
|
||||
}
|
||||
if (plazaPublic || pageDataPublic) return next();
|
||||
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
|
||||
if (!req.userSession) {
|
||||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||||
}
|
||||
|
||||
+12
-12
@@ -1483,24 +1483,24 @@ export async function listOwnerPageDataPolicies(): Promise<
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
}>('/page-data/policies');
|
||||
}>('/admin/page-data/policies');
|
||||
return result.data.policies;
|
||||
}
|
||||
|
||||
export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') {
|
||||
const params = new URLSearchParams({ format });
|
||||
return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
|
||||
return `/api/admin/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function listPageDataDatasets(): Promise<PageDataDatasetSummary[]> {
|
||||
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data');
|
||||
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/admin/page-data');
|
||||
return result.data.datasets;
|
||||
}
|
||||
|
||||
export async function getPageDataPolicy(pageId: string): Promise<PageDataAccessPolicy | null> {
|
||||
try {
|
||||
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||
);
|
||||
return result.data.policy;
|
||||
} catch (error) {
|
||||
@@ -1522,7 +1522,7 @@ export async function applyPageDataPublishPolicy(
|
||||
},
|
||||
): Promise<PageDataAccessPolicy> {
|
||||
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -1539,7 +1539,7 @@ export async function savePageDataPolicy(
|
||||
policy: Partial<PageDataAccessPolicy>,
|
||||
): Promise<PageDataAccessPolicy> {
|
||||
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(policy),
|
||||
@@ -1550,7 +1550,7 @@ export async function savePageDataPolicy(
|
||||
|
||||
export async function getPageDataOpsOverview(pageId: string): Promise<PageDataOpsOverview> {
|
||||
const result = await apiFetch<{ data: PageDataOpsOverview }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}/ops`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}/ops`,
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
@@ -1564,7 +1564,7 @@ export async function listPageDataLogs(
|
||||
if (input.offset != null) params.set('offset', String(input.offset));
|
||||
const query = params.toString();
|
||||
const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
@@ -1574,7 +1574,7 @@ export async function revokePageDataTokens(
|
||||
input: { revokeAll?: boolean; token?: string },
|
||||
): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> {
|
||||
const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -1592,7 +1592,7 @@ export async function resetPageDataPassword(
|
||||
): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> {
|
||||
const result = await apiFetch<{
|
||||
data: { pageId: string; passwordReset: boolean; revokedSessions: number };
|
||||
}>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
|
||||
}>(`/admin/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
@@ -1604,7 +1604,7 @@ export async function closePageDataDataset(
|
||||
dataset: string,
|
||||
): Promise<{ pageId: string; dataset: string; closed: boolean }> {
|
||||
const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>(
|
||||
`/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
|
||||
`/admin/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.data;
|
||||
@@ -1615,7 +1615,7 @@ export async function restorePageDataRow(
|
||||
rowId: number | string,
|
||||
): Promise<{ restored: boolean; row: Record<string, unknown> }> {
|
||||
const result = await apiFetch<{ data: { restored: boolean; row: Record<string, unknown> } }>(
|
||||
`/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
|
||||
`/admin/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.data;
|
||||
|
||||
@@ -674,6 +674,18 @@ export function createUserDataSpaceService(options = {}) {
|
||||
return { dataset, id, deleted: true };
|
||||
}
|
||||
|
||||
async function hardDeleteRowForDataset(dataset, rowId, meta = {}) {
|
||||
assertDatasetAction(dataset, 'hard_delete');
|
||||
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 scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : '';
|
||||
await executeSql(`DELETE FROM "${table}" WHERE id = ${id}${scopeClause};`);
|
||||
return { dataset, id, deleted: true, permanentlyDeleted: true };
|
||||
}
|
||||
|
||||
async function restoreSoftDeletedRowForDataset(dataset, rowId) {
|
||||
const table = assertSafeSqlIdentifier(dataset.table, '表名');
|
||||
const id = Number(rowId);
|
||||
@@ -795,6 +807,7 @@ export function createUserDataSpaceService(options = {}) {
|
||||
updateRowForDataset,
|
||||
softDeleteDatasetRow,
|
||||
softDeleteRowForDataset,
|
||||
hardDeleteRowForDataset,
|
||||
restoreSoftDeletedRowForDataset,
|
||||
restoreSoftDeletedRow,
|
||||
exportDatasetRows,
|
||||
|
||||
Reference in New Issue
Block a user