fix(page-data): bind Page Data pages structurally on deliver, not by intent

Finish/sync now auto-registers datasets and binds any HTML using page-data-client.js
before publication, so user confirmation cannot publish unbound collect pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 00:17:58 +08:00
parent da422a82e6
commit fb055c6721
10 changed files with 700 additions and 90 deletions
+1
View File
@@ -31,6 +31,7 @@ const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
const PAGE_DATA_INTENT_PATTERNS = [
/(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u,
/(?:报名表|报名登记|在线报名|收集报名)/u,
/(?:作业登记|作业打卡|作业记录|每日作业|打卡登记|台账|记录表)/u,
/(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u,
/(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u,
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
@@ -252,7 +252,7 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
storageRoot: workspaceRoot,
});
assert.equal(autoBind.bound.length, 0);
assert.equal(autoBind.errors[0]?.code, 'database_unconfigured');
assert.equal(autoBind.errors[0]?.code, 'missing_context');
writePageAccessPolicy(workspaceRoot, {
pageId: 'page-diet-survey',
@@ -326,11 +326,11 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
}
});
test('integration: finish guard never scans historical Page Data files without a current HTML write', async () => {
test('integration: finish guard repairs historical unbound Page Data html without a current write', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-'));
try {
await setupSurveyWorkspace(workspaceRoot);
fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), VALID_SURVEY_HTML, 'utf8');
const result = await maybeRepairPageDataAfterFinish({
sessionId: 'session-with-history',
userId: 'user-page-data',
@@ -339,12 +339,13 @@ test('integration: finish guard never scans historical Page Data files without a
pool: null,
h5Root: workspaceRoot,
storageRoot: workspaceRoot,
userText: SURVEY_USER_TEXT,
userText: '确认发布',
tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } },
});
assert.equal(result.repaired, false);
assert.equal(result.structuralPageData, true);
assert.equal(result.relevantFiles.length, 1);
assert.equal(result.needsRepair, true);
assert.equal(result.triggered, undefined);
assert.equal(result.relevantFiles.length, 0);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
+70 -81
View File
@@ -7,8 +7,8 @@ import {
htmlUsesPageDataApi,
inferPageDataBindAccessMode,
} from './page-data-html-detect.mjs';
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
import { createPageService } from './mindspace-pages.mjs';
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
import {
assessPageDataHtmlBinding,
assessWorkspacePageDataReadiness,
@@ -240,17 +240,41 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } =
return [...targets];
}
function isStructuralPageDataHtmlFile(file) {
return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi);
}
function resolvePageDataGuardAgentText({ agentText = '', messages = [] } = {}) {
const direct = String(agentText ?? '').trim();
if (isPageDataIntent(direct)) return direct;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== 'user') continue;
const text = Array.isArray(message?.content)
? message.content
.filter((item) => item?.type === 'text')
.map((item) => String(item.text ?? '').trim())
.filter(Boolean)
.join('\n')
: String(message?.content ?? '').trim();
if (isPageDataIntent(text)) return text;
}
return direct;
}
export function evaluatePageDataFinishGuard({
publishDir,
agentText = '',
messages = [],
requestStartedAt = 0,
} = {}) {
const pageDataIntent = isPageDataIntent(agentText);
const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages });
const pageDataIntent = isPageDataIntent(resolvedAgentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
const relevantFiles = pageDataFiles.filter((file) =>
pageDataIntent || recentWrites.includes(file.relativePath),
const structuralFiles = pageDataFiles.filter(isStructuralPageDataHtmlFile);
const relevantFiles = pageDataFiles.filter(
(file) => isStructuralPageDataHtmlFile(file) || recentWrites.includes(file.relativePath),
);
const htmlIssues = relevantFiles.flatMap((file) =>
@@ -271,13 +295,17 @@ export function evaluatePageDataFinishGuard({
);
const needsRepair =
pageDataIntent &&
structuralFiles.length > 0 &&
(htmlIssues.length > 0 ||
unboundFiles.length > 0 ||
(relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages)));
(pageDataIntent &&
relevantFiles.length === 0 &&
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
usedPageDataCollectSkill(messages)));
return {
pageDataIntent,
structuralPageData: structuralFiles.length > 0,
relevantFiles,
htmlIssues,
unboundFiles,
@@ -310,10 +338,11 @@ export async function evaluatePageDataFinishGuardAsync({
findPageByRelativePath,
});
const needsRepair =
base.pageDataIntent &&
base.structuralPageData &&
(base.htmlIssues.length > 0 ||
unboundFiles.length > 0 ||
(base.relevantFiles.length === 0 &&
(base.pageDataIntent &&
base.relevantFiles.length === 0 &&
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
usedPageDataCollectSkill(messages)));
@@ -403,16 +432,15 @@ export function resolvePageDataCollectOutcome({
requestStartedAt = 0,
}) {
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
if (!isPageDataIntent(agentText)) {
return { action: 'skip' };
}
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText,
messages: reply?.messages ?? [],
requestStartedAt,
});
if (!evaluation.structuralPageData && !evaluation.pageDataIntent) {
return { action: 'skip' };
}
if (evaluation.htmlIssues.length > 0) {
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
@@ -438,10 +466,6 @@ export async function resolvePageDataCollectOutcomeAsync({
fetchImpl = fetch,
} = {}) {
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
if (!isPageDataIntent(agentText)) {
return { action: 'skip' };
}
const evaluation = await evaluatePageDataFinishGuardAsync({
publishDir,
agentText,
@@ -451,6 +475,9 @@ export async function resolvePageDataCollectOutcomeAsync({
userId,
findPageByRelativePath,
});
if (!evaluation.structuralPageData && !evaluation.pageDataIntent) {
return { action: 'skip' };
}
if (evaluation.htmlIssues.length > 0) {
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
@@ -471,7 +498,18 @@ export async function resolvePageDataCollectOutcomeAsync({
fetchImpl,
});
if (failures.length === 0) {
return { action: 'send', reason: 'verified_by_live_api', evaluation };
const reassessment = await evaluatePageDataFinishGuardAsync({
publishDir,
agentText,
messages: reply?.messages ?? [],
requestStartedAt,
pool,
userId,
findPageByRelativePath,
});
if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) {
return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment };
}
}
}
}
@@ -503,66 +541,15 @@ export async function maybeAutoBindPageDataHtmlPages({
onlyRelativePaths = null,
findPageByRelativePath = null,
} = {}) {
if (!pool) {
return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] };
}
const pageLookup =
typeof findPageByRelativePath === 'function'
? findPageByRelativePath
: createPageService(pool, { h5Root, storageRoot }).findPageByRelativePath;
const bound = [];
const skipped = [];
const errors = [];
const allowList = onlyRelativePaths ? new Set(onlyRelativePaths) : null;
for (const file of collectPageDataPublicHtmlFiles(publishDir)) {
if (allowList && !allowList.has(file.relativePath)) continue;
if (file.evaluation.issues.length > 0) {
skipped.push({ relativePath: file.relativePath, reason: 'invalid_html', issues: file.evaluation.issues });
continue;
}
if (file.evaluation.usage?.size === 0) {
skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' });
continue;
}
const assessment = await assessPageDataHtmlBinding({
pool,
userId,
publishDir,
relativePath: file.relativePath,
html: file.content,
findPageByRelativePath: pageLookup,
});
if (assessment.bound) {
skipped.push({ relativePath: file.relativePath, reason: 'already_bound' });
continue;
}
try {
const accessMode = inferPageDataBindAccessMode(file.relativePath, file.content);
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot: publishDir,
relativePath: file.relativePath,
accessMode,
password: accessMode === 'password' ? '88888888' : null,
});
bound.push({ relativePath: file.relativePath, pageId: result.pageId, workspaceUrl: result.workspaceUrl });
} catch (err) {
errors.push({
relativePath: file.relativePath,
message: err instanceof Error ? err.message : String(err),
code: err?.code ?? 'bind_failed',
});
}
}
return { bound, skipped, errors };
return ensurePageDataHtmlPagesBound({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot: publishDir,
findPageByRelativePath,
onlyRelativePaths,
});
}
export async function ensurePageDataDeliveryReady({
@@ -609,14 +596,14 @@ export async function maybeRepairPageDataAfterFinish({
const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null;
const evaluation = await evaluatePageDataFinishGuardAsync({
publishDir,
agentText: recentUserText,
agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }),
messages,
pool,
userId,
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
});
if (!evaluation.pageDataIntent && evaluation.relevantFiles.length === 0) {
if (!evaluation.structuralPageData && evaluation.relevantFiles.length === 0) {
resetPageDataFinishGuardAttempts(sessionId);
return { repaired: false, skipped: 'not_page_data', ...evaluation };
}
@@ -627,13 +614,15 @@ export async function maybeRepairPageDataAfterFinish({
publishDir,
h5Root,
storageRoot,
onlyRelativePaths: evaluation.relevantFiles.map((file) => file.relativePath),
onlyRelativePaths: evaluation.relevantFiles.length
? evaluation.relevantFiles.map((file) => file.relativePath)
: null,
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
});
const afterBind = await evaluatePageDataFinishGuardAsync({
publishDir,
agentText: recentUserText,
agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }),
messages,
pool,
userId,
+1 -1
View File
@@ -108,7 +108,7 @@ test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => {
storageRoot: publishDir,
});
assert.equal(result.bound.length, 0);
assert.equal(result.errors[0]?.code, 'database_unconfigured');
assert.equal(result.errors[0]?.code, 'missing_context');
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
const PAGE_DATA_HTML = `<!doctype html><html><body>
<script src="/assets/page-data-client.js"></script>
<script>MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('survey', { q1: 'a' });</script>
</body></html>`;
test('ensureWorkspaceHtmlPublications skips page-data html and delegates to pageDataEnsure', async () => {
const h5Root = fs.mkdtempSync(path.join(os.tmpdir(), 'deliver-h5-'));
const userId = 'user-1';
const publishDir = path.join(h5Root, 'MindSpace', userId);
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'survey.html'), PAGE_DATA_HTML, 'utf8');
const published = [];
const bound = [];
const service = createWorkspacePageDeliverService({
pool: {
async query() {
return [[{
page_id: 'page-1',
title: '问卷',
current_version_id: 'ver-1',
workspace_relative_path: 'public/survey.html',
source_snapshot_json: JSON.stringify({
auto_synced: true,
relative_path: 'public/survey.html',
content_mode: 'static_html',
}),
}]];
},
},
pageService: {
async getPage() {
return { id: 'page-1', title: '问卷', currentVersionId: 'ver-1' };
},
findPageByRelativePath: async () => null,
},
publicationService: {
async getCurrent() {
return null;
},
async publish(userIdArg, pageId, input) {
published.push({ userId: userIdArg, pageId, input });
return { id: 'pub-1' };
},
},
pageSyncService: {
async syncUserGeneratedPages() {
return { created: 0, updated: 0, skipped: 0 };
},
},
pageDataEnsure: {
async ensurePageDataHtmlPagesBound() {
bound.push(true);
return { bound: [{ relativePath: 'public/survey.html' }], skipped: [], errors: [] };
},
},
h5Root,
storageRoot: null,
});
const result = await service.syncAndDeliver(userId);
assert.equal(bound.length, 1);
assert.equal(result.publish.published, 0);
assert.equal(result.publish.skipped, 1);
assert.equal(published.length, 0);
fs.rmSync(h5Root, { recursive: true, force: true });
});
+64 -1
View File
@@ -1,5 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { htmlUsesPageDataApi } from './page-data-html-detect.mjs';
import { resolvePublishDir } from './user-publish.mjs';
function parseJsonColumn(value, fallback = {}) {
if (value == null || value === '') return { ...fallback };
@@ -16,11 +20,31 @@ function isPublicWorkspaceHtmlPath(relativePath) {
return Boolean(normalized?.startsWith('public/') && normalized.toLowerCase().endsWith('.html'));
}
function readWorkspaceHtmlContent(publishDir, relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized || !publishDir) return '';
const absolutePath = path.join(publishDir, ...normalized.split('/'));
try {
return fs.readFileSync(absolutePath, 'utf8');
} catch {
return '';
}
}
function isPageDataWorkspaceHtml(publishDir, relativePath) {
const content = readWorkspaceHtmlContent(publishDir, relativePath);
return htmlUsesPageDataApi(content);
}
export function createWorkspacePageDeliverService({
pool,
pageService,
publicationService,
pageSyncService,
pageDataEnsure = null,
h5Root = null,
storageRoot = null,
findPageByRelativePath = null,
logger = console,
} = {}) {
async function listUnpublishedAutoSyncedWorkspacePages(userId) {
@@ -66,6 +90,30 @@ export function createWorkspacePageDeliverService({
});
}
function resolveWorkspaceRoot(userId) {
if (!h5Root || !userId) return null;
return resolvePublishDir(h5Root, { id: userId });
}
async function ensurePageDataBindings(userId) {
if (!pageDataEnsure?.ensurePageDataHtmlPagesBound || !pool || !userId) {
return { bound: [], skipped: [], errors: [] };
}
const workspaceRoot = resolveWorkspaceRoot(userId);
if (!workspaceRoot) {
return { bound: [], skipped: [], errors: [{ code: 'missing_workspace_root' }] };
}
return pageDataEnsure.ensurePageDataHtmlPagesBound({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot,
findPageByRelativePath,
logger,
});
}
async function refreshOnlineWorkspacePublications(userId) {
if (!publicationService?.refreshOnlinePublicationHtml || !userId) {
return { refreshed: 0, skipped: 0, errors: [] };
@@ -101,11 +149,19 @@ export function createWorkspacePageDeliverService({
return { published: 0, skipped: 0, errors: [] };
}
const candidates = await listUnpublishedAutoSyncedWorkspacePages(userId);
const publishDir = resolveWorkspaceRoot(userId);
let published = 0;
let skipped = 0;
const errors = [];
for (const candidate of candidates) {
try {
const snapshot = parseJsonColumn(candidate.source_snapshot_json);
const relativePath =
candidate.workspace_relative_path ?? snapshot.relative_path ?? null;
if (isPageDataWorkspaceHtml(publishDir, relativePath)) {
skipped += 1;
continue;
}
const page = await pageService.getPage(userId, candidate.page_id);
const current = await publicationService.getCurrent?.(userId, candidate.page_id);
if (current?.status === 'online') {
@@ -138,13 +194,20 @@ export function createWorkspacePageDeliverService({
if (pageSyncService?.syncUserGeneratedPages) {
syncResult = await pageSyncService.syncUserGeneratedPages(userId);
}
const pageDataBindResult = await ensurePageDataBindings(userId);
const publishResult = await ensureWorkspaceHtmlPublications(userId);
const refreshResult = await refreshOnlineWorkspacePublications(userId);
return { sync: syncResult, publish: publishResult, refresh: refreshResult };
return {
sync: syncResult,
pageDataBind: pageDataBindResult,
publish: publishResult,
refresh: refreshResult,
};
}
return {
syncAndDeliver,
ensurePageDataBindings,
ensureWorkspaceHtmlPublications,
refreshOnlineWorkspacePublications,
};
+193
View File
@@ -0,0 +1,193 @@
import fs from 'node:fs';
import path from 'node:path';
import { assessPageDataHtmlBinding } from './page-data-delivery-assess.mjs';
import {
detectPageDataDatasetUsageFromHtml,
htmlUsesPageDataApi,
inferPageDataBindAccessMode,
} from './page-data-html-detect.mjs';
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { assertSafeSqlIdentifier } from './user-data-space-service.mjs';
const PAGE_DATA_ADMIN_PASSWORD = '88888888';
function readPublicHtmlFiles(workspaceRoot) {
const publicDir = path.join(path.resolve(String(workspaceRoot ?? '')), 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return [];
return fs
.readdirSync(publicDir)
.filter((name) => name.toLowerCase().endsWith('.html'))
.map((name) => {
const relativePath = `public/${name}`;
const absolutePath = path.join(publicDir, name);
const content = fs.readFileSync(absolutePath, 'utf8');
return { relativePath, absolutePath, content };
});
}
export function inferInsertColumnsFromHtml(html, datasetName) {
const text = String(html ?? '');
const safeName = String(datasetName ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [
new RegExp(`\\.insertRow\\(\\s*['"]${safeName}['"]\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*[,)]`, 'm'),
new RegExp(
`\\.insertRow\\(\\s*([A-Za-z_$][\\w$]*)\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*[,)]`,
'm',
),
];
const constants = new Map();
for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g)) {
constants.set(match[1], match[2]);
}
for (const pattern of patterns) {
const match = pattern.exec(text);
if (!match) continue;
const resolvedDataset = match.length === 3 ? constants.get(match[1]) ?? match[1] : datasetName;
if (String(resolvedDataset).trim() !== String(datasetName).trim()) continue;
const body = match[match.length - 1] ?? '';
const columns = [];
for (const fieldMatch of body.matchAll(/([A-Za-z_][\w$]*)\s*:/g)) {
const column = assertSafeSqlIdentifier(fieldMatch[1], 'insert 字段');
if (!columns.includes(column)) columns.push(column);
}
if (columns.length) return columns;
}
return [];
}
export async function ensureRegisteredDatasetFromHtml({
workspaceRoot,
userId = null,
query = null,
html,
datasetName,
}) {
const dataSpace = createUserDataSpaceService({ workspaceRoot, userId, query });
const existing = dataSpace.getDataset(datasetName);
if (existing) return existing;
const insertColumns = inferInsertColumnsFromHtml(html, datasetName);
if (!insertColumns.length) {
throw Object.assign(
new Error(`无法从 HTML 推断 dataset「${datasetName}」的 insert 字段,请先 register_dataset`),
{ code: 'insert_columns_unknown', datasetName },
);
}
const tableName = assertSafeSqlIdentifier(datasetName, 'dataset 表名');
const columnSql = insertColumns
.map((column) => `${column} TEXT NOT NULL DEFAULT ''`)
.join(',\n ');
await dataSpace.executeSql(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
${columnSql},
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);`,
);
const readColumns = ['id', ...insertColumns, 'created_at'];
return dataSpace.upsertDataset({
name: datasetName,
table: tableName,
description: `Auto-registered from workspace HTML (${datasetName})`,
actions: ['read', 'insert'],
columns: {
read: readColumns,
insert: insertColumns,
},
});
}
export function listPageDataHtmlFiles(workspaceRoot) {
return readPublicHtmlFiles(workspaceRoot).filter((file) => htmlUsesPageDataApi(file.content));
}
export async function ensurePageDataHtmlPagesBound({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot,
findPageByRelativePath = null,
onlyRelativePaths = null,
logger = console,
} = {}) {
if (!pool || !userId || !workspaceRoot) {
return {
bound: [],
skipped: [],
errors: [{ code: 'missing_context', message: '缺少 pool/userId/workspaceRoot' }],
};
}
const allowList = onlyRelativePaths ? new Set(onlyRelativePaths) : null;
const bound = [];
const skipped = [];
const errors = [];
for (const file of listPageDataHtmlFiles(workspaceRoot)) {
if (allowList && !allowList.has(file.relativePath)) continue;
const usage = detectPageDataDatasetUsageFromHtml(file.content);
if (!usage.size) {
skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' });
continue;
}
const assessment = await assessPageDataHtmlBinding({
pool,
userId,
publishDir: workspaceRoot,
relativePath: file.relativePath,
html: file.content,
findPageByRelativePath,
});
if (assessment.bound) {
skipped.push({ relativePath: file.relativePath, reason: 'already_bound' });
continue;
}
try {
for (const datasetName of usage.keys()) {
await ensureRegisteredDatasetFromHtml({
workspaceRoot,
userId,
query: pool.query.bind(pool),
html: file.content,
datasetName,
});
}
const accessMode = inferPageDataBindAccessMode(file.relativePath, file.content);
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot,
relativePath: file.relativePath,
accessMode,
password: accessMode === 'password' ? PAGE_DATA_ADMIN_PASSWORD : null,
});
bound.push({
relativePath: file.relativePath,
pageId: result.pageId,
workspaceUrl: result.workspaceUrl,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push({
relativePath: file.relativePath,
message,
code: err?.code ?? 'bind_failed',
});
logger.warn?.(
`[PageData] ensure bind failed for ${file.relativePath}: ${message}`,
);
}
}
return { bound, skipped, errors };
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
ensureRegisteredDatasetFromHtml,
inferInsertColumnsFromHtml,
} from './page-data-workspace-ensure.mjs';
import { evaluatePageDataFinishGuard } from './mindspace-page-data-finish-guard.mjs';
const HOMEWORK_HTML = `<!doctype html><html><head><title>作业</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('homework_records', {
student_name: 'a',
record_date: '2026-07-12',
chinese: '已完成',
math: '已完成',
english: '已完成',
note: ''
});
</script></body></html>`;
test('inferInsertColumnsFromHtml parses insertRow object keys', () => {
const columns = inferInsertColumnsFromHtml(HOMEWORK_HTML, 'homework_records');
assert.deepEqual(columns, [
'student_name',
'record_date',
'chinese',
'math',
'english',
'note',
]);
});
test('ensureRegisteredDatasetFromHtml creates sqlite registry from html', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ensure-'));
try {
await ensureRegisteredDatasetFromHtml({
workspaceRoot,
html: HOMEWORK_HTML,
datasetName: 'homework_records',
});
const dbPath = path.join(workspaceRoot, '.mindspace', 'private-data.sqlite');
assert.ok(fs.existsSync(dbPath));
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test('evaluatePageDataFinishGuard detects unbound homework html even when user only confirms', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-homework-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'homework.html'), HOMEWORK_HTML, 'utf8');
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText: '确认发布',
messages: [],
});
assert.equal(evaluation.pageDataIntent, false);
assert.equal(evaluation.structuralPageData, true);
assert.equal(evaluation.unboundFiles.length, 1);
assert.equal(evaluation.needsRepair, true);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* 生产 runtime 安全:补建作业登记 Page Datasqlite + workspace policy 文件 + policy index)。
* 不依赖完整 dev 模块树(db.mjs / page-data-workspace-bind 等)。
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(repoRoot, '.env'));
const USER_ID = '0a763620-c0d6-4e88-9f0a-6cc68840cf7a';
const DATASET = 'homework_records';
const TABLE = 'homework_records';
const REGISTRY = '__page_data_datasets';
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
const ADMIN_PASSWORD = '88888888';
const PAGES = [
{
pageId: '553c3b47-01eb-4294-9ce8-dfb6b2576990',
relativePath: 'public/homework.html',
accessMode: 'public',
policy: {
accessMode: 'public',
defaultVisitorRole: 'deny',
datasets: {
[DATASET]: {
read: false,
insert: true,
columns: {
insert: ['student_name', 'record_date', 'chinese', 'math', 'english', 'note'],
},
},
},
},
},
{
pageId: 'f8720a88-e04d-4c40-938f-11b7fcd41d50',
relativePath: 'public/homework-admin.html',
accessMode: 'password',
policy: {
accessMode: 'password',
defaultVisitorRole: 'deny',
datasets: {
[DATASET]: {
read: true,
insert: false,
columns: {
read: ['id', 'student_name', 'record_date', 'chinese', 'math', 'english', 'note', 'created_at'],
},
},
},
},
},
];
function runSqlite(dbPath, sql) {
execFileSync(SQLITE_BIN, ['-batch', dbPath, sql], { stdio: 'pipe' });
}
function sqlLiteral(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function buildScopeHash(policy) {
const payload = {
accessMode: policy.accessMode,
datasets: Object.keys(policy.datasets).sort(),
};
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
}
function writePolicy(workspaceRoot, pageId, policyInput) {
const policyDir = path.join(workspaceRoot, '.mindspace', 'page-data-policies');
fs.mkdirSync(policyDir, { recursive: true });
const policy = {
pageId,
ownerUserId: USER_ID,
workspaceRef: null,
...policyInput,
visitors: [],
roles: {},
updatedAt: new Date().toISOString(),
};
const policyPath = path.join(policyDir, `${pageId}.json`);
fs.writeFileSync(policyPath, `${JSON.stringify(policy, null, 2)}\n`, 'utf8');
return policy;
}
const workspaceRoot = path.join(repoRoot, 'MindSpace', USER_ID);
if (!fs.existsSync(workspaceRoot)) {
console.error(`工作区不存在: ${workspaceRoot}`);
process.exit(1);
}
const mindspaceDir = path.join(workspaceRoot, '.mindspace');
const dbPath = path.join(mindspaceDir, 'private-data.sqlite');
fs.mkdirSync(mindspaceDir, { recursive: true });
console.log('1/3 初始化 sqlite 表与 dataset 注册…');
runSqlite(
dbPath,
`PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS ${TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_name TEXT NOT NULL,
record_date TEXT NOT NULL,
chinese TEXT NOT NULL,
math TEXT NOT NULL,
english TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
CREATE TABLE IF NOT EXISTS ${REGISTRY} (
name TEXT PRIMARY KEY,
table_name TEXT NOT NULL,
config_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
);
const datasetConfig = {
name: DATASET,
table: TABLE,
description: '每日作业登记(语数外)',
actions: ['read', 'insert'],
columns: {
read: ['id', 'student_name', 'record_date', 'chinese', 'math', 'english', 'note', 'created_at'],
insert: ['student_name', 'record_date', 'chinese', 'math', 'english', 'note'],
},
limits: { maxRowsPerRead: 200, maxInsertBytes: 8192 },
};
runSqlite(
dbPath,
`INSERT INTO ${REGISTRY} (name, table_name, config_json, updated_at)
VALUES (${sqlLiteral(DATASET)}, ${sqlLiteral(TABLE)}, ${sqlLiteral(JSON.stringify(datasetConfig))}, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
table_name = excluded.table_name,
config_json = excluded.config_json,
updated_at = CURRENT_TIMESTAMP;`,
);
console.log('2/3 写入 workspace policy 文件…');
const writtenPolicies = [];
for (const page of PAGES) {
const htmlPath = path.join(workspaceRoot, page.relativePath);
if (!fs.existsSync(htmlPath)) {
console.error(`缺少 HTML: ${page.relativePath}`);
process.exit(1);
}
const policy = writePolicy(workspaceRoot, page.pageId, page.policy);
writtenPolicies.push(policy);
console.log(`${page.relativePath}${page.pageId}.json`);
}
if (!process.env.DATABASE_URL && !(process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)) {
console.warn('3/3 跳过 MySQL policy index(未配置数据库)');
process.exit(0);
}
console.log('3/3 同步 MySQL policy index…');
const pool = process.env.DATABASE_URL
? mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 })
: mysql.createPool({
host: process.env.MYSQL_HOST ?? 'localhost',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
connectionLimit: 2,
});
const now = Date.now();
for (const policy of writtenPolicies) {
const datasetCount = Object.keys(policy.datasets).length;
await pool.query(
`INSERT INTO h5_page_data_policy_index
(page_id, owner_user_id, access_mode, dataset_count, scope_hash, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
owner_user_id = VALUES(owner_user_id),
access_mode = VALUES(access_mode),
dataset_count = VALUES(dataset_count),
scope_hash = VALUES(scope_hash),
updated_at = VALUES(updated_at)`,
[policy.pageId, USER_ID, policy.accessMode, datasetCount, buildScopeHash(policy), now],
);
}
await pool.end();
console.log('\n完成。');
console.log(` 登记页:https://m.tkmind.cn/MindSpace/${USER_ID}/public/homework.html`);
console.log(` 后台页:https://m.tkmind.cn/MindSpace/${USER_ID}/public/homework-admin.html`);
console.log(` 后台口令:${ADMIN_PASSWORD}(平台要求至少 8 位;「888」无效)`);
+5 -1
View File
@@ -463,6 +463,10 @@ async function bootstrapUserAuth() {
pageService: mindSpacePages,
publicationService: mindSpacePublications,
pageSyncService: mindSpacePageSync,
pageDataEnsure: { ensurePageDataHtmlPagesBound },
h5Root: __dirname,
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
findPageByRelativePath: mindSpacePages.findPageByRelativePath.bind(mindSpacePages),
logger: console,
});
const resolveUserIdByDirKey = async (dirKey) => {
@@ -5068,6 +5072,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
.filter(Boolean)
.join('\n')
: '';
await syncUserGeneratedPages(uid);
await maybeRepairPageDataAfterFinish({
sessionId: sid,
userId: uid,
@@ -5079,7 +5084,6 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
tkmindProxy,
userText: lastUserText,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
onAfterFinish,