feat(page-data): add private and public dataset API (phase 1-3)
Extract UserDataSpaceService for shared SQLite access, wire logged-in Page Data routes, and add public insert plus password-token read/update/delete with policy storage, rate limits, and regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { mapPageDataPublicError } from './page-data-public-service.mjs';
|
||||
|
||||
function requireUser(req, res, sendError) {
|
||||
if (!req.currentUser?.id) {
|
||||
sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||||
return null;
|
||||
}
|
||||
return req.currentUser;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function parseRowPayload(req) {
|
||||
const payload = req.body?.row && typeof req.body.row === 'object' ? req.body.row : req.body;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function handlePublicError(res, req, sendError, error) {
|
||||
const mapped = mapPageDataPublicError(error);
|
||||
return sendError(res, req, mapped.status, mapped.code, mapped.message);
|
||||
}
|
||||
|
||||
export function attachPageDataRoutes(api, deps) {
|
||||
const { sendData, sendError, getPageDataService, getPageDataPublicService } = deps;
|
||||
|
||||
api.get('/page-data', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const datasets = await service.listDatasets(user);
|
||||
return sendData(res, req, { datasets });
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/policies/:pageId', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const policy = await publicService.getOwnerPolicy(user, req.params.pageId);
|
||||
if (!policy) return sendError(res, req, 404, 'policy_not_found', '页面数据策略未配置');
|
||||
return sendData(res, req, { policy });
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.put('/page-data/policies/:pageId', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const policy = await publicService.saveOwnerPolicy(user, req.params.pageId, req.body ?? {});
|
||||
return sendData(res, req, { policy });
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const result = await service.listRows(user, req.params.dataset, {
|
||||
limit: parsePositiveInt(req.query.limit, undefined),
|
||||
offset: parsePositiveInt(req.query.offset, 0),
|
||||
orderBy: req.query.order_by,
|
||||
orderDir: req.query.order_dir,
|
||||
});
|
||||
return sendData(res, req, {
|
||||
dataset: result.dataset.name,
|
||||
rows: result.rows,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset/schema', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const schema = await service.getSchema(user, req.params.dataset);
|
||||
return sendData(res, req, schema);
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/page-data/:dataset/stats', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const stats = await service.getStats(user, req.params.dataset);
|
||||
return sendData(res, req, stats);
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/page-data/:dataset/rows', async (req, res) => {
|
||||
const user = requireUser(req, res, sendError);
|
||||
if (!user) return;
|
||||
const service = getPageDataService?.();
|
||||
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
const payload = parseRowPayload(req);
|
||||
if (!payload) return sendError(res, req, 400, 'invalid_request', '提交数据必须是 JSON 对象');
|
||||
try {
|
||||
const result = await service.insertRow(user, req.params.dataset, payload);
|
||||
return sendData(res, req, { dataset: result.dataset.name, row: result.row }, 201);
|
||||
} catch (error) {
|
||||
const status = error?.status ?? 400;
|
||||
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '写入失败');
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/public/pages/:pageId/data-auth', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
const password = String(req.body?.password ?? '').trim();
|
||||
if (!password) return sendError(res, req, 400, 'invalid_request', '缺少页面口令');
|
||||
try {
|
||||
const auth = await publicService.authenticate(req.params.pageId, password, req);
|
||||
return sendData(res, req, auth);
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/public/pages/:pageId/data/:dataset', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const result = await publicService.listRows(req.params.pageId, req.params.dataset, req, {
|
||||
limit: parsePositiveInt(req.query.limit, undefined),
|
||||
offset: parsePositiveInt(req.query.offset, 0),
|
||||
orderBy: req.query.order_by,
|
||||
orderDir: req.query.order_dir,
|
||||
});
|
||||
return sendData(res, req, {
|
||||
dataset: result.dataset.name,
|
||||
rows: result.rows,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/public/pages/:pageId/data/:dataset/schema', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const schema = await publicService.getSchema(req.params.pageId, req.params.dataset, req);
|
||||
return sendData(res, req, schema);
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/public/pages/:pageId/data/:dataset/stats', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const stats = await publicService.getStats(req.params.pageId, req.params.dataset, req);
|
||||
return sendData(res, req, stats);
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/public/pages/:pageId/data/:dataset/rows', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
const payload = parseRowPayload(req);
|
||||
if (!payload) return sendError(res, req, 400, 'invalid_request', '提交数据必须是 JSON 对象');
|
||||
try {
|
||||
const result = await publicService.insertRow(req.params.pageId, req.params.dataset, req, payload);
|
||||
return sendData(res, req, { dataset: result.dataset.name, row: result.row }, 201);
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.patch('/public/pages/:pageId/data/:dataset/rows/:rowId', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
const payload = parseRowPayload(req);
|
||||
if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象');
|
||||
try {
|
||||
const result = await publicService.updateRow(
|
||||
req.params.pageId,
|
||||
req.params.dataset,
|
||||
req.params.rowId,
|
||||
req,
|
||||
payload,
|
||||
);
|
||||
return sendData(res, req, { dataset: result.dataset.name, row: result.row });
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.delete('/public/pages/:pageId/data/:dataset/rows/:rowId', async (req, res) => {
|
||||
const publicService = getPageDataPublicService?.();
|
||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||
try {
|
||||
const result = await publicService.softDeleteRow(
|
||||
req.params.pageId,
|
||||
req.params.dataset,
|
||||
req.params.rowId,
|
||||
req,
|
||||
);
|
||||
return sendData(res, req, result);
|
||||
} catch (error) {
|
||||
return handlePublicError(res, req, sendError, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user