import fs from 'node:fs/promises'; import { detectPageDataDatasetUsageFromHtml, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; const INJECTION_PAGE_ID_PATTERN = /window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*"pageId"\s*:\s*"([^"]+)"/i; const INJECTION_META_PAGE_ID_PATTERN = /]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i; export function extractInjectedPageIdFromHtml(html) { const content = String(html ?? ''); const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim(); if (fromScript) return fromScript; return content.match(INJECTION_META_PAGE_ID_PATTERN)?.[1]?.trim() ?? null; } export function buildSmokeInsertRow(policy, datasetName) { const columns = policy?.datasets?.[datasetName]?.columns?.insert ?? []; const row = {}; for (const column of columns) { if (/rating|score|星级|评分/i.test(column)) { row[column] = 3; } else if (/phone|手机|电话/i.test(column)) { row[column] = '13800000000'; } else { row[column] = 'smoke-test'; } } return row; } export async function queryOnlinePublication(pool, pageId) { if (!pool || !pageId) return null; const [rows] = await pool.query( `SELECT id, page_id, status, access_mode FROM h5_publish_records WHERE page_id = ? AND status = 'online' ORDER BY published_at DESC LIMIT 1`, [pageId], ); return rows[0] ?? null; } function findWorkspacePolicyForHtml({ publishDir, relativePath, html }) { const usage = detectPageDataDatasetUsageFromHtml(html); const htmlDatasetNames = [...usage.keys()].sort().join(','); const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html); for (const policy of listPageAccessPolicies(publishDir)) { const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(','); if (policyNames !== htmlDatasetNames) continue; if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) continue; let matches = true; for (const [datasetName, perms] of usage) { const policyDataset = policy.datasets?.[datasetName]; if (!policyDataset) { matches = false; break; } if (perms.insert && !policyDataset.insert) matches = false; if (perms.read && !policyDataset.read) matches = false; } if (matches) return policy; } return null; } export function assessWorkspacePageDataReadiness({ publishDir, relativePath, html }) { const usage = detectPageDataDatasetUsageFromHtml(html); if (!usage.size) { return { ready: true, reasons: [] }; } const reasons = []; const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir }); for (const datasetName of usage.keys()) { if (!dataSpace.getDataset(datasetName)) { reasons.push(`dataset_not_registered:${datasetName}`); } } const policy = findWorkspacePolicyForHtml({ publishDir, relativePath, html }); if (!policy) { reasons.push('missing_workspace_policy'); } return { ready: reasons.length === 0, reasons, policy }; } export async function assessPageDataHtmlBinding({ pool, userId, publishDir, relativePath, html, findPageByRelativePath, }) { const usage = detectPageDataDatasetUsageFromHtml(html); if (!usage.size) { return { bound: true, reasons: [], pageId: null }; } const workspace = assessWorkspacePageDataReadiness({ publishDir, relativePath, html }); const reasons = [...workspace.reasons]; let pageId = null; if (typeof findPageByRelativePath === 'function') { const page = await findPageByRelativePath(userId, relativePath).catch(() => null); pageId = page?.id ? String(page.id).trim() : null; if (!pageId) { reasons.push('missing_page_record'); } } else { reasons.push('missing_page_lookup'); } if (pageId) { const policy = readPageAccessPolicy(publishDir, pageId); if (!policy) { reasons.push('missing_policy_for_page'); } else { const htmlDatasetNames = [...usage.keys()].sort().join(','); const policyNames = Object.keys(policy.datasets ?? {}).sort().join(','); if (htmlDatasetNames !== policyNames) { reasons.push('policy_dataset_mismatch'); } const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html); if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) { reasons.push('policy_access_mode_mismatch'); } for (const [datasetName, perms] of usage) { const policyDataset = policy.datasets?.[datasetName]; if (!policyDataset) continue; if (perms.insert && !policyDataset.insert) reasons.push('missing_insert_permission'); if (perms.read && !policyDataset.read) reasons.push('missing_read_permission'); } } const publication = await queryOnlinePublication(pool, pageId); if (!publication) { reasons.push('missing_online_publication'); } } return { bound: reasons.length === 0, reasons: [...new Set(reasons)], pageId, policy: pageId ? readPageAccessPolicy(publishDir, pageId) : workspace.policy ?? null, }; } export async function fetchWorkspaceHtmlInjection({ url, fetchImpl = fetch }) { const response = await fetchImpl(String(url)); if (!response.ok) { return { ok: false, reason: `http_${response.status}`, pageId: null, html: '', }; } const html = await response.text(); const pageId = extractInjectedPageIdFromHtml(html); if (!pageId) { return { ok: false, reason: 'missing_page_data_injection', pageId: null, html }; } return { ok: true, reason: null, pageId, html }; } export async function smokeTestPageDataInsert({ apiBase, pageId, dataset, policy, fetchImpl = fetch, }) { const base = String(apiBase ?? '').replace(/\/$/, '') || '/api'; const row = buildSmokeInsertRow(policy, dataset); if (!pageId || !dataset || !Object.keys(row).length) { return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null }; } const response = await fetchImpl( `${base}/public/pages/${encodeURIComponent(pageId)}/data/${encodeURIComponent(dataset)}/rows`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(row), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { return { ok: false, reason: body?.error?.code ?? 'insert_failed', status: response.status, body, }; } return { ok: true, reason: null, status: response.status, body }; } export async function verifyPageDataDeliveryArtifacts({ artifacts = [], publishDir, apiBase, pool, userId, findPageByRelativePath, fetchImpl = fetch, }) { const failures = []; for (const artifact of artifacts) { if (artifact.isAdmin) continue; const relativePath = artifact.relativePath; const html = await fs.readFile(artifact.localPath, 'utf8').catch(() => ''); const usage = detectPageDataDatasetUsageFromHtml(html); const insertDataset = [...usage.entries()].find(([, perms]) => perms.insert)?.[0] ?? null; if (!insertDataset) continue; const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl }); if (!injection.ok) { failures.push({ relativePath, stage: 'injection', reason: injection.reason }); continue; } const assessment = await assessPageDataHtmlBinding({ pool, userId, publishDir, relativePath, html, findPageByRelativePath, }); if (!assessment.bound) { failures.push({ relativePath, stage: 'binding', reason: assessment.reasons.join(','), }); continue; } const smoke = await smokeTestPageDataInsert({ apiBase, pageId: assessment.pageId, dataset: insertDataset, policy: assessment.policy, fetchImpl, }); if (!smoke.ok) { failures.push({ relativePath, stage: 'insert_smoke', reason: smoke.reason, status: smoke.status, }); } } return failures; }