fix(page-data): enforce full workspace bind delivery and repair tooling
Prevent false-positive Finish Guard passes and sync-created fake bindings by assessing publication, policy, dataset, injection, and insert smoke before H5 delivery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 为已知破损问卷补注册 dataset(表已存在但 __page_data_datasets 未登记的场景)。
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createUserDataSpaceService } from '../user-data-space-service.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const PRESETS = {
|
||||
'a70ff537-8908-486e-9b6c-042e07cc25db': [
|
||||
{
|
||||
name: 'company_suggestions',
|
||||
table: 'company_suggestions',
|
||||
description: '公司建议问卷调查',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: [
|
||||
'id',
|
||||
'department',
|
||||
'q_work_env',
|
||||
'q_management',
|
||||
'q_teamwork',
|
||||
'q_salary_welfare',
|
||||
'q_dev_opportunity',
|
||||
'q_other_suggestion',
|
||||
'overall_rating',
|
||||
'created_at',
|
||||
],
|
||||
insert: [
|
||||
'department',
|
||||
'q_work_env',
|
||||
'q_management',
|
||||
'q_teamwork',
|
||||
'q_salary_welfare',
|
||||
'q_dev_opportunity',
|
||||
'q_other_suggestion',
|
||||
'overall_rating',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'diet_survey',
|
||||
table: 'diet_survey',
|
||||
description: '儿童饮食偏好调查',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: ['id', 'child_age', 'veggie_habit', 'snack_type', 'created_at'],
|
||||
insert: ['child_age', 'veggie_habit', 'snack_type'],
|
||||
},
|
||||
},
|
||||
],
|
||||
'a6fb1e97-2b0f-447b-b138-4561d8e5c53e': [
|
||||
{
|
||||
name: 'dining_survey',
|
||||
table: 'dining_survey',
|
||||
description: '餐饮偏好调查问卷',
|
||||
sql: `CREATE TABLE IF NOT EXISTS dining_survey (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
q1_frequency TEXT NOT NULL DEFAULT '',
|
||||
q2_priority TEXT NOT NULL DEFAULT '',
|
||||
q3_cuisine TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||
);`,
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: ['id', 'q1_frequency', 'q2_priority', 'q3_cuisine', 'created_at'],
|
||||
insert: ['q1_frequency', 'q2_priority', 'q3_cuisine'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const userIds = process.argv.slice(2);
|
||||
const targets = userIds.length ? userIds : Object.keys(PRESETS);
|
||||
for (const userId of targets) {
|
||||
const presets = PRESETS[userId];
|
||||
if (!presets) {
|
||||
console.warn(`跳过 ${userId}:无 preset`);
|
||||
continue;
|
||||
}
|
||||
const workspaceRoot = path.join(repoRoot, 'MindSpace', userId);
|
||||
const dataSpace = createUserDataSpaceService({ workspaceRoot });
|
||||
for (const preset of presets) {
|
||||
if (preset.sql) {
|
||||
await dataSpace.executeSql(preset.sql);
|
||||
console.log(`[${userId}] 已确保表 ${preset.table}`);
|
||||
}
|
||||
if (!dataSpace.getDataset(preset.name)) {
|
||||
await dataSpace.upsertDataset({
|
||||
name: preset.name,
|
||||
table: preset.table,
|
||||
description: preset.description,
|
||||
actions: preset.actions,
|
||||
columns: preset.columns,
|
||||
});
|
||||
console.log(`[${userId}] 已注册 dataset ${preset.name}`);
|
||||
} else {
|
||||
console.log(`[${userId}] dataset ${preset.name} 已存在`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -133,6 +133,7 @@ if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 扫描并补绑工作区中未完整交付的 Page Data 问卷页(publication + policy + dataset)。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs --user-id <uuid>
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs --dry-run
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { buildPublicUrl, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from '../user-publish.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
import { createPageService } from '../mindspace-pages.mjs';
|
||||
import { assessPageDataHtmlBinding } from '../page-data-delivery-assess.mjs';
|
||||
import {
|
||||
collectPageDataPublicHtmlFiles,
|
||||
maybeAutoBindPageDataHtmlPages,
|
||||
} from '../mindspace-page-data-finish-guard.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { userId: null, dryRun: false };
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
|
||||
else if (arg === '-h' || arg === '--help') {
|
||||
console.log('Usage: node scripts/repair-page-data-workspace-bindings.mjs [--user-id <uuid>] [--dry-run]');
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function listWorkspaceUserIds(mindspaceRoot) {
|
||||
if (!fs.existsSync(mindspaceRoot)) return [];
|
||||
return fs
|
||||
.readdirSync(mindspaceRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^[0-9a-f-]{36}$/i.test(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
async function assessUserWorkspace({ userId, pool, h5Root, storageRoot, publicBaseUrl }) {
|
||||
const publishDir = path.join(h5Root, 'MindSpace', userId);
|
||||
if (!fs.existsSync(publishDir)) return [];
|
||||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||||
const broken = [];
|
||||
for (const file of collectPageDataPublicHtmlFiles(publishDir)) {
|
||||
if (file.evaluation.usage?.size === 0) continue;
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
if (!assessment.bound) {
|
||||
broken.push({
|
||||
userId,
|
||||
relativePath: file.relativePath,
|
||||
url: buildPublicUrl(publicBaseUrl, userId, file.relativePath),
|
||||
reasons: assessment.reasons,
|
||||
pageId: assessment.pageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
const pool = createDbPool();
|
||||
const h5Root = repoRoot;
|
||||
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
|
||||
const publicBaseUrl = resolvePublicBaseUrl();
|
||||
const userIds = options.userId ? [options.userId] : listWorkspaceUserIds(path.join(h5Root, 'MindSpace'));
|
||||
|
||||
const allBroken = [];
|
||||
for (const userId of userIds) {
|
||||
const broken = await assessUserWorkspace({
|
||||
userId,
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
publicBaseUrl,
|
||||
});
|
||||
allBroken.push(...broken);
|
||||
}
|
||||
|
||||
if (!allBroken.length) {
|
||||
console.log('未发现需要补绑的 Page Data 页面。');
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`发现 ${allBroken.length} 个未完整绑定的 Page Data 页面:`);
|
||||
for (const item of allBroken) {
|
||||
console.log(`- [${item.userId}] ${item.relativePath}`);
|
||||
console.log(` url: ${item.url}`);
|
||||
console.log(` reasons: ${item.reasons.join(', ')}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n(dry-run) 未执行补绑。');
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const byUser = new Map();
|
||||
for (const item of allBroken) {
|
||||
const list = byUser.get(item.userId) ?? [];
|
||||
list.push(item.relativePath);
|
||||
byUser.set(item.userId, list);
|
||||
}
|
||||
|
||||
for (const [userId, relativePaths] of byUser.entries()) {
|
||||
const publishDir = path.join(h5Root, 'MindSpace', userId);
|
||||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||||
const result = await maybeAutoBindPageDataHtmlPages({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
onlyRelativePaths: relativePaths,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
console.log(
|
||||
`\n[user ${userId}] bound=${result.bound.length} skipped=${result.skipped.length} errors=${result.errors.length}`,
|
||||
);
|
||||
for (const item of result.bound) {
|
||||
console.log(` ✓ ${item.relativePath} -> ${item.pageId}`);
|
||||
}
|
||||
for (const item of result.errors) {
|
||||
console.log(` ✗ ${item.relativePath}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -579,8 +579,14 @@ export async function verifyChildrenHobbyDietSurvey({
|
||||
if (response.status !== 200) {
|
||||
reporter.fail(label, `${response.status} ${url}`);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
const html = await response.text();
|
||||
if (!html.includes('__MINDSPACE_PAGE_DATA__')) {
|
||||
reporter.fail(`${label} Page Data 注入`, '响应缺少 __MINDSPACE_PAGE_DATA__');
|
||||
ok = false;
|
||||
} else {
|
||||
reporter.pass(label, url);
|
||||
reporter.pass(`${label} Page Data 注入`, url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user