diff --git a/.env.example b/.env.example index 27f31d1..6d17b31 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ TKMIND_API_TARGET=https://127.0.0.1:18006 TKMIND_SERVER__SECRET_KEY=local-dev-secret H5_PORT=8081 +# Vite UI 预览用 5173;Agent 交付的 MindSpace 公开页 + Page Data API 走 Portal(8081)。 +# pnpm dev / dev-core 在未显式覆盖时会把 H5_PUBLIC_BASE_URL 设为 8081。 H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。 diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index edd78f3..87bbbbd 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import { isPageDataIntent } from './chat-skills.mjs'; import { detectPageDataDatasetUsageFromHtml, + htmlUsesForbiddenLegacyPageDataApi, htmlUsesPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; @@ -15,7 +16,7 @@ import { normalizePageDataApiBase, verifyPageDataDeliveryArtifacts, } from './page-data-delivery-assess.mjs'; -import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs'; +import { buildPublicUrl, resolvePageDataDeliveryBaseUrl } from './user-publish.mjs'; export { inferPageDataBindAccessMode }; @@ -50,7 +51,7 @@ function readPublicHtmlFiles(publishDir) { export function collectPageDataDeliveryArtifacts( publishDir, - { publicBaseUrl = resolvePublicBaseUrl() } = {}, + { publicBaseUrl = resolvePageDataDeliveryBaseUrl() } = {}, ) { const ownerKey = path.basename(path.resolve(String(publishDir ?? ''))); if (!ownerKey) return []; @@ -112,13 +113,17 @@ export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) { const usesPageDataApi = usage.size > 0 || PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) || - /\bMindSpacePageData\b/.test(content); + /\bMindSpacePageData\b/.test(content) || + htmlUsesForbiddenLegacyPageDataApi(content); if (!usesPageDataApi) { return { usesPageDataApi: false, issues: [] }; } const issues = []; + if (htmlUsesForbiddenLegacyPageDataApi(content)) { + issues.push('forbidden_legacy_page_data_api'); + } if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) { issues.push('missing_page_data_client_script'); } @@ -382,7 +387,7 @@ export function buildPageDataCollectFailureText() { '这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。', '请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:', '建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。', - '数据必须走平台 API 写入 SQLite,禁止 localStorage 或自建后端。', + '数据必须走平台 API(MindSpacePageData.createClient + /api/public/pages/...),禁止 /api/page-data/ 旁路、localStorage 或自建后端。', ].join(''); } @@ -394,8 +399,9 @@ export function buildPageDataCollectRepairPrompt({ '【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:', '1. load_skill → page-data-collect', '2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })', - '3. 禁止 localStorage / 浏览器本地存储 fallback', + '3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端', '4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)', + '5. 向用户交付的链接必须使用 Portal(本地 http://127.0.0.1:8081),不要用 Vite 5173', ]; if (htmlIssues.length) { lines.push('', 'HTML 问题:'); @@ -507,34 +513,50 @@ export async function resolvePageDataCollectOutcomeAsync({ return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; } - if (evaluation.unboundFiles.length > 0 && pool && userId && apiBase) { - const artifacts = collectPageDataDeliveryArtifacts(publishDir).filter((artifact) => - evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath), - ); - if (artifacts.length > 0) { - const failures = await verifyPageDataDeliveryArtifacts({ - artifacts, + const deliveryArtifacts = + evaluation.relevantFiles.length > 0 + ? collectPageDataDeliveryArtifacts(publishDir).filter((artifact) => + evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath), + ) + : []; + + if (pool && userId && apiBase && deliveryArtifacts.length > 0) { + const deliveryFailures = await verifyPageDataDeliveryArtifacts({ + artifacts: deliveryArtifacts, + publishDir, + apiBase, + pool, + userId, + findPageByRelativePath, + fetchImpl, + }); + if (deliveryFailures.length === 0) { + const reassessment = await evaluatePageDataFinishGuardAsync({ publishDir, - apiBase, + agentText, + messages: reply?.messages ?? [], + requestStartedAt, pool, userId, findPageByRelativePath, - fetchImpl, }); - if (failures.length === 0) { - 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 }; - } + if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) { + return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment }; } + } else if (evaluation.unboundFiles.length > 0) { + return { + action: 'retry', + reason: 'missing_bind', + evaluation, + deliveryFailures, + }; + } else { + return { + action: 'retry', + reason: 'delivery_smoke_failed', + evaluation, + deliveryFailures, + }; } } diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 335b3ee..763c6fc 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -35,6 +35,10 @@ async function save(data) { } `; +const LEGACY_API_HTML = ``; + test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => { const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' }); assert.deepEqual(ok.issues, []); @@ -44,6 +48,30 @@ test('evaluatePageDataHtmlContent flags missing client script and localStorage f assert.ok(bad.issues.includes('forbidden_local_storage')); }); +test('evaluatePageDataHtmlContent flags forbidden /api/page-data passthrough', () => { + const bad = evaluatePageDataHtmlContent(LEGACY_API_HTML, { relativePath: 'public/daily-todo.html' }); + assert.ok(bad.issues.includes('forbidden_legacy_page_data_api')); + assert.ok(bad.issues.includes('missing_page_data_client_script')); +}); + +test('finish guard blocks delivery when html uses legacy page-data API', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-legacy-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'daily-todo.html'), LEGACY_API_HTML, 'utf8'); + const evaluation = evaluatePageDataFinishGuard({ + publishDir, + agentText: '帮我设计一个调查问卷,要加一个后台', + messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/daily-todo.html' } } } }] }], + }); + assert.equal(evaluation.pageDataIntent, true); + assert.ok(evaluation.htmlIssues.some((item) => item.issue === 'forbidden_legacy_page_data_api')); + assert.equal(evaluation.needsRepair, true); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + test('inferPageDataBindAccessMode chooses password for admin html', () => { assert.equal( inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML), @@ -191,6 +219,28 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', () } }); +test('collectPageDataDeliveryArtifacts uses Portal base when H5_PUBLIC_BASE_URL points at Vite', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-portal-url-')); + const previousBase = process.env.H5_PUBLIC_BASE_URL; + const previousDelivery = process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL; + delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL; + process.env.H5_PUBLIC_BASE_URL = 'http://127.0.0.1:5173'; + process.env.H5_PORT = '8081'; + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'form.html'), SURVEY_HTML, 'utf8'); + const artifacts = collectPageDataDeliveryArtifacts(publishDir); + assert.equal(artifacts.length, 1); + assert.match(artifacts[0].url, /^http:\/\/127\.0\.0\.1:8081\/MindSpace\//); + } finally { + if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL; + else process.env.H5_PUBLIC_BASE_URL = previousBase; + if (previousDelivery == null) delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL; + else process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL = previousDelivery; + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace workspace urls', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-urls-')); const previousBase = process.env.H5_PUBLIC_BASE_URL; diff --git a/page-data-delivery-assess.mjs b/page-data-delivery-assess.mjs index df0c39d..58f0d73 100644 --- a/page-data-delivery-assess.mjs +++ b/page-data-delivery-assess.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import { detectPageDataDatasetUsageFromHtml, + htmlUsesForbiddenLegacyPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs'; @@ -107,12 +108,23 @@ export async function assessPageDataHtmlBinding({ findPageByRelativePath, }) { const usage = detectPageDataDatasetUsageFromHtml(html); + const reasons = []; + + if (htmlUsesForbiddenLegacyPageDataApi(html)) { + reasons.push('forbidden_legacy_page_data_api'); + } + if (!usage.size) { - return { bound: true, reasons: [], pageId: null }; + return { + bound: reasons.length === 0, + reasons: [...new Set(reasons)], + pageId: null, + policy: null, + }; } const workspace = assessWorkspacePageDataReadiness({ publishDir, relativePath, html }); - const reasons = [...workspace.reasons]; + reasons.push(...workspace.reasons); let pageId = null; if (typeof findPageByRelativePath === 'function') { @@ -147,6 +159,13 @@ export async function assessPageDataHtmlBinding({ } } + if (pool && pageId) { + const publication = await queryOnlinePublication(pool, pageId); + const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html); + if (publication && String(publication.access_mode ?? '').trim() !== expectedAccessMode) { + reasons.push('publication_access_mode_mismatch'); + } + } } return { @@ -219,12 +238,30 @@ export async function verifyPageDataDeliveryArtifacts({ }) { 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 assessment = await assessPageDataHtmlBinding({ + pool, + userId, + publishDir, + relativePath, + html, + findPageByRelativePath, + }); + if (!assessment.bound) { + failures.push({ + relativePath, + stage: 'binding', + reason: assessment.reasons.join(',') || 'not_bound', + bindingReasons: assessment.reasons, + }); + continue; + } + + if (artifact.isAdmin || !insertDataset) continue; const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl }); if (!injection.ok) { @@ -234,15 +271,7 @@ export async function verifyPageDataDeliveryArtifacts({ const pageId = injection.pageId; const policy = - readPageAccessPolicy(publishDir, pageId) ?? - (await assessPageDataHtmlBinding({ - pool, - userId, - publishDir, - relativePath, - html, - findPageByRelativePath, - })).policy; + readPageAccessPolicy(publishDir, pageId) ?? assessment.policy; const smoke = await smokeTestPageDataInsert({ apiBase, @@ -255,14 +284,6 @@ export async function verifyPageDataDeliveryArtifacts({ continue; } - const assessment = await assessPageDataHtmlBinding({ - pool, - userId, - publishDir, - relativePath, - html, - findPageByRelativePath, - }); failures.push({ relativePath, stage: 'insert_smoke', diff --git a/page-data-html-detect.mjs b/page-data-html-detect.mjs index d34b341..4662249 100644 --- a/page-data-html-detect.mjs +++ b/page-data-html-detect.mjs @@ -3,6 +3,11 @@ */ const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i; +const FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN = /\/api\/page-data\b/i; + +export function htmlUsesForbiddenLegacyPageDataApi(html) { + return FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN.test(String(html ?? '')); +} export function htmlUsesPageDataApi(html) { const content = String(html ?? ''); @@ -10,7 +15,8 @@ export function htmlUsesPageDataApi(html) { return ( usage.size > 0 || PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) || - /\bMindSpacePageData\b/.test(content) + /\bMindSpacePageData\b/.test(content) || + htmlUsesForbiddenLegacyPageDataApi(content) ); } diff --git a/page-data-html-detect.test.mjs b/page-data-html-detect.test.mjs index 1a9884f..56c0c53 100644 --- a/page-data-html-detect.test.mjs +++ b/page-data-html-detect.test.mjs @@ -4,8 +4,17 @@ import { assertPolicyMatchesHtmlDatasets, buildPageDataPolicyDatasetsFromRegistry, detectPageDataDatasetUsageFromHtml, + htmlUsesForbiddenLegacyPageDataApi, + htmlUsesPageDataApi, } from './page-data-html-detect.mjs'; +test('htmlUsesForbiddenLegacyPageDataApi detects /api/page-data passthrough', () => { + const html = `fetch('/api/page-data/daily_todo', { method: 'POST' })`; + assert.equal(htmlUsesForbiddenLegacyPageDataApi(html), true); + assert.equal(htmlUsesPageDataApi(html), true); + assert.equal(detectPageDataDatasetUsageFromHtml(html).size, 0); +}); + test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => { const html = ` await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' }); diff --git a/page-data-workspace-bind.mjs b/page-data-workspace-bind.mjs index c9f24b6..454ed7b 100644 --- a/page-data-workspace-bind.mjs +++ b/page-data-workspace-bind.mjs @@ -8,7 +8,7 @@ import { normalizePageAccessPolicy } from './page-access-policy.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs'; -import { resolvePublicBaseUrl } from './user-publish.mjs'; +import { resolvePageDataDeliveryBaseUrl } from './user-publish.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { assertPolicyMatchesHtmlDatasets, @@ -127,7 +127,7 @@ async function ensureWorkspaceHtmlPublished({ } function buildWorkspacePublicUrl(userId, relativePath) { - const publicBaseUrl = resolvePublicBaseUrl(); + const publicBaseUrl = resolvePageDataDeliveryBaseUrl(); return createMindSpacePublicUrl({ publicBaseUrl, ownerKey: userId, diff --git a/scripts/repair-daily-todo-local.mjs b/scripts/repair-daily-todo-local.mjs new file mode 100644 index 0000000..73b27ad --- /dev/null +++ b/scripts/repair-daily-todo-local.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/** + * 修复 john 用户「每日待办登记」Page Data 交付: + * - 前台页改为 public + insertRow + * - 后台页 password + listRows/updateRow/deleteRow + * + * Usage: node scripts/repair-daily-todo-local.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs'; +import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs'; +import { readPageAccessPolicy, writePageAccessPolicy } from '../page-data-policy-store.mjs'; + +import { resolvePageDataDeliveryBaseUrl } from '../user-publish.mjs'; + +const root = 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(root, '.env')); +loadEnvFile(path.join(root, '../../.env.local')); + +const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID); +const ADMIN_PASSWORD = 'adminadmin'; +const DATASET = 'daily_todo'; +const LOCAL_BASE = process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL || resolvePageDataDeliveryBaseUrl(); + +const FORM_POLICY = { + accessMode: 'public', + datasets: { + [DATASET]: { + insert: true, + read: false, + columns: { + insert: ['name', 'date', 'todo_items', 'priority', 'status', 'remark'], + }, + }, + }, +}; + +const ADMIN_POLICY = { + accessMode: 'password', + datasets: { + [DATASET]: { + insert: false, + read: true, + update: true, + softDelete: true, + columns: { + read: ['id', 'name', 'date', 'todo_items', 'priority', 'status', 'remark', 'created_at', 'updated_at'], + update: ['name', 'date', 'todo_items', 'priority', 'status', 'remark', 'updated_at'], + soft_delete: ['id'], + }, + }, + }, +}; + +async function bindPages(pool, h5Root, storageRoot) { + const pages = [ + { + relativePath: 'public/daily-todo.html', + accessMode: 'public', + password: null, + policy: FORM_POLICY, + }, + { + relativePath: 'public/daily-todo-admin.html', + accessMode: 'password', + password: ADMIN_PASSWORD, + policy: ADMIN_POLICY, + }, + ]; + + const bound = []; + for (const page of pages) { + const result = await bindWorkspaceHtmlForPageData({ + pool, + h5Root, + storageRoot, + userId: JOHN_USER_ID, + workspaceRoot: WORKSPACE_ROOT, + relativePath: page.relativePath, + accessMode: page.accessMode, + password: page.password, + pageDataPolicy: page.policy, + }); + bound.push({ + relativePath: page.relativePath, + pageId: result.pageId, + workspaceUrl: result.workspaceUrl, + publicationAccessMode: result.publicationAccessMode, + }); + } + return bound; +} + +function patchAdminPolicySoftDelete(pageId) { + const policy = readPageAccessPolicy(WORKSPACE_ROOT, pageId); + if (!policy?.datasets?.[DATASET]) return policy; + policy.datasets[DATASET].update = true; + policy.datasets[DATASET].softDelete = true; + policy.datasets[DATASET].hardDelete = false; + policy.datasets[DATASET].columns.update = ADMIN_POLICY.datasets[DATASET].columns.update; + return writePageAccessPolicy(WORKSPACE_ROOT, policy); +} + +async function smokeInsert(pageId) { + const res = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}/rows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + name: '本地验证', + date: '2026-07-13', + todo_items: '测试待办', + priority: '普通', + status: '待完成', + remark: 'repair script', + }), + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +async function smokeAdminList(pageId) { + const authRes = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data-auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password: ADMIN_PASSWORD }), + }); + const authBody = await authRes.json().catch(() => ({})); + const token = authBody?.data?.token; + if (!token) return { status: authRes.status, body: authBody, listStatus: 0 }; + + const listRes = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}?limit=5`, { + headers: { Accept: 'application/json', 'x-page-data-token': token }, + }); + const listBody = await listRes.json().catch(() => ({})); + return { status: authRes.status, listStatus: listRes.status, rows: listBody?.data?.rows?.length ?? 0 }; +} + +const pool = createDbPool(); +const h5Root = root; +const storageRoot = resolveMindSpaceStorageRoot(h5Root); + +console.log('=== 每日待办登记 · 本地修复 ===\n'); + +const bound = await bindPages(pool, h5Root, storageRoot); +console.log('✔ 页面已重新绑定\n'); +for (const item of bound) { + console.log(` ${item.relativePath}`); + console.log(` pageId: ${item.pageId}`); + console.log(` access: ${item.publicationAccessMode}`); + console.log(` url: ${item.workspaceUrl}\n`); +} + +const adminPageId = bound.find((item) => item.relativePath === 'public/daily-todo-admin.html')?.pageId; +if (adminPageId) { + patchAdminPolicySoftDelete(adminPageId); + console.log('✔ 后台策略已启用 update + softDelete\n'); +} + +const registerPageId = bound.find((item) => item.relativePath === 'public/daily-todo.html')?.pageId; +const insert = registerPageId ? await smokeInsert(registerPageId) : { status: 0 }; +const admin = adminPageId ? await smokeAdminList(adminPageId) : { listStatus: 0 }; + +console.log('【insert 冒烟】', JSON.stringify(insert, null, 2)); +console.log('【admin 冒烟】', JSON.stringify(admin, null, 2)); + +console.log('\n=== 正确访问链接(Portal 8081)==='); +for (const item of bound) { + console.log(` ${item.relativePath}: ${item.workspaceUrl}`); +} +console.log(` 后台管理密码: ${ADMIN_PASSWORD}`); +console.log('\n注意:Agent 交付链接必须用 Portal(8081),不要用 Vite UI(5173)。'); + +const passed = insert.status === 201 && admin.listStatus === 200; +console.log(`\n${passed ? '✔ 修复完成,链路可用' : '✘ 仍有问题,请检查上方输出'}`); + +await pool.end(); +process.exit(passed ? 0 : 1); diff --git a/scripts/verify-daily-register-local.mjs b/scripts/verify-daily-register-local.mjs new file mode 100644 index 0000000..7d0d584 --- /dev/null +++ b/scripts/verify-daily-register-local.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * 本地验证「每日登记事项」Page Data 交付链路: + * 1. 绑定前守卫应拦截(与生产 WeChat 一致) + * 2. 补 dataset + bind 后守卫应放行 + * 3. insert API 冒烟 + * + * Usage: node scripts/verify-daily-register-local.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs'; +import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs'; +import { ensureRegisteredDatasetFromHtml } from '../page-data-workspace-ensure.mjs'; +import { + resolvePageDataCollectOutcomeAsync, + evaluatePageDataFinishGuardAsync, +} from '../mindspace-page-data-finish-guard.mjs'; +import { createPageService } from '../mindspace-pages.mjs'; + +const root = 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(root, '.env')); +loadEnvFile(path.join(root, '../../.env.local')); + +const USER_MSG = + '帮我做一个"每日登记事项":填写姓名、年龄、最喜欢的阅读类型;需要一个填写页面和一个后台查看页面。页面做好后请把两个链接都发给我。'; +const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID); +const ADMIN_PASSWORD = '88888888'; +const DATASET = 'daily_register'; +const LOCAL_BASE = process.env.MEMIND_PUBLIC_BASE_URL || 'http://127.0.0.1:8081'; +const PROD_OWNER = 'a70ff537-8908-486e-9b6c-042e07cc25db'; + +const TOOL_MESSAGES = [ + { + content: [{ + type: 'toolRequest', + toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/daily-register.html' } } }, + }], + }, + { + content: [{ + type: 'toolRequest', + toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/daily-admin.html' } } }, + }], + }, +]; + +function patchAdminHtmlForPageData(html) { + if (/listRows\s*\(\s*['"]daily_register['"]/.test(html)) return html; + const scriptStart = html.indexOf('', scriptStart); + if (scriptStart < 0 || scriptEnd < 0) { + throw new Error('daily-admin.html 结构不符合预期,无法注入 Page Data 脚本'); + } + const replacement = ` + '.length)}`; +} + +async function ensureProdHtmlLocally() { + const publicDir = path.join(WORKSPACE_ROOT, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + for (const name of ['daily-register.html', 'daily-admin.html']) { + const target = path.join(publicDir, name); + const url = `https://m.tkmind.cn/MindSpace/${PROD_OWNER}/public/${name}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`拉取生产 HTML 失败 ${name}: ${res.status}`); + let html = await res.text(); + if (name === 'daily-admin.html') html = patchAdminHtmlForPageData(html); + fs.writeFileSync(target, html, 'utf8'); + } +} + +async function assessGuard(pool, pageService, label) { + const guard = await evaluatePageDataFinishGuardAsync({ + publishDir: WORKSPACE_ROOT, + agentText: USER_MSG, + messages: TOOL_MESSAGES, + pool, + userId: JOHN_USER_ID, + findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), + }); + const outcome = await resolvePageDataCollectOutcomeAsync({ + reply: { text: '问卷和后台已创建,可以提交。', messages: TOOL_MESSAGES }, + intent: { agentText: USER_MSG }, + publishDir: WORKSPACE_ROOT, + pool, + userId: JOHN_USER_ID, + findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), + apiBase: LOCAL_BASE, + }); + return { + label, + needsRepair: guard.needsRepair, + unboundFiles: guard.unboundFiles.map((file) => file.relativePath), + htmlIssues: guard.htmlIssues, + outcomeAction: outcome.action, + outcomeReason: outcome.reason, + }; +} + +async function bindPages(pool, h5Root, storageRoot) { + const registerHtml = fs.readFileSync(path.join(WORKSPACE_ROOT, 'public/daily-register.html'), 'utf8'); + await ensureRegisteredDatasetFromHtml({ + workspaceRoot: WORKSPACE_ROOT, + userId: JOHN_USER_ID, + html: registerHtml, + datasetName: DATASET, + }); + + const pages = [ + { + relativePath: 'public/daily-register.html', + accessMode: 'public', + password: null, + policy: { + accessMode: 'public', + datasets: { + [DATASET]: { + insert: true, + read: false, + columns: { insert: ['name', 'age', 'reading_type'] }, + }, + }, + }, + }, + { + relativePath: 'public/daily-admin.html', + accessMode: 'password', + password: ADMIN_PASSWORD, + policy: { + accessMode: 'password', + datasets: { + [DATASET]: { + insert: false, + read: true, + columns: { read: ['id', 'name', 'age', 'reading_type', 'created_at'] }, + }, + }, + }, + }, + ]; + + const bound = []; + for (const page of pages) { + const result = await bindWorkspaceHtmlForPageData({ + pool, + h5Root, + storageRoot, + userId: JOHN_USER_ID, + workspaceRoot: WORKSPACE_ROOT, + relativePath: page.relativePath, + accessMode: page.accessMode, + password: page.password, + pageDataPolicy: page.policy, + }); + bound.push({ + relativePath: page.relativePath, + pageId: result.pageId, + workspaceUrl: result.workspaceUrl, + publicationUrl: result.publicationUrl, + }); + } + return bound; +} + +async function smokeInsert(pageId) { + const res = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}/rows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + name: '本地验证', + age: 28, + reading_type: '科学技术', + }), + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +const pool = createDbPool(); +const h5Root = root; +const storageRoot = resolveMindSpaceStorageRoot(h5Root); +const pageService = createPageService(pool, { h5Root, storageRoot }); + +console.log('=== 每日登记事项 · 本地验证 ===\n'); +await ensureProdHtmlLocally(); +console.log('✔ 已同步生产 HTML 到本地 john 工作区(后台页已改为 Page Data listRows)\n'); + +const before = await assessGuard(pool, pageService, '绑定前'); +console.log('【绑定前】', JSON.stringify(before, null, 2)); + +const bound = await bindPages(pool, h5Root, storageRoot); +console.log('\n✔ 绑定完成'); +for (const item of bound) { + console.log(` ${item.relativePath}`); + console.log(` pageId: ${item.pageId}`); + console.log(` url: ${item.workspaceUrl}`); +} + +const after = await assessGuard(pool, pageService, '绑定后'); +console.log('\n【绑定后】', JSON.stringify(after, null, 2)); + +const registerPageId = bound.find((item) => item.relativePath === 'public/daily-register.html')?.pageId; +const insert = registerPageId ? await smokeInsert(registerPageId) : { status: 0, body: { error: 'missing pageId' } }; +console.log('\n【insert 冒烟】', JSON.stringify(insert, null, 2)); + + const passed = + before.outcomeAction !== 'send' && + after.outcomeAction !== 'fail' && + insert.status === 201; + + console.log('\n=== 补充:Agent 未 load page-data-collect 技能时 ==='); + console.log('绑定完成后守卫仍可能报 incomplete_delivery(要求 Agent 走正式技能链路)'); + console.log('若消息含 load_skill(page-data-collect) + bind 工具,守卫 outcome = send'); + + console.log('\n=== 本地可访问链接(已 bind) ==='); + for (const item of bound) { + console.log(` ${item.relativePath}: ${item.workspaceUrl.replace('5173', '8081')}`); + } + console.log(` 后台密码: ${ADMIN_PASSWORD}`); + + console.log('\n=== 汇总 ==='); +console.log(`绑定前守卫: ${before.outcomeAction} (${before.outcomeReason ?? 'n/a'})`); +console.log(`绑定后守卫: ${after.outcomeAction} (${after.outcomeReason ?? 'n/a'})`); +console.log(`insert API: HTTP ${insert.status}`); +console.log(passed ? '✔ 本地验证通过:问题可复现且修复后交付完整' : '✘ 本地验证未完全通过,请检查上方输出'); + +await pool.end(); +process.exit(passed ? 0 : 1); diff --git a/user-publish.mjs b/user-publish.mjs index 31ae257..62325d2 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -50,6 +50,28 @@ export function resolvePublicBaseUrl(env = process.env) { return (env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/$/, ''); } +const LOCAL_VITE_PUBLIC_BASE_PATTERN = /^https?:\/\/(?:127\.0\.0\.1|localhost):5173$/i; + +/** Agent 交付的 MindSpace 公开页 + Page Data API 应走 Portal,本地开发时勿用 Vite 5173。 */ +export function resolvePageDataDeliveryBaseUrl(env = process.env) { + const explicit = String( + env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL ?? env.H5_PORTAL_BASE_URL ?? '', + ) + .trim() + .replace(/\/$/, ''); + if (explicit) return explicit; + + const publicBase = String(env.H5_PUBLIC_BASE_URL ?? '').trim().replace(/\/$/, ''); + const portalPort = Number(env.H5_PORT ?? 8081); + const portalBase = `http://127.0.0.1:${portalPort}`; + + if (publicBase && LOCAL_VITE_PUBLIC_BASE_PATTERN.test(publicBase)) { + return portalBase; + } + if (publicBase) return publicBase; + return portalBase; +} + export const PUBLISH_KEY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/user-publish.test.mjs b/user-publish.test.mjs index 0d2d322..45846e6 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -17,6 +17,7 @@ import { PUBLIC_ZONE_DIR, resolveLegacyPublishDir, resolveGoosedSandboxRoot, + resolvePageDataDeliveryBaseUrl, } from './user-publish.mjs'; import { syncSkillsToWorkspace } from './skills-registry.mjs'; import { listPlatformSkillCatalog } from './skills-registry.mjs'; @@ -157,3 +158,21 @@ test('isPathInsidePublishDir blocks escape', () => { assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/${USER_ID}/report.html`), true); assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/other-id/x.html`), false); }); + +test('resolvePageDataDeliveryBaseUrl prefers Portal when public base points at local Vite', () => { + assert.equal( + resolvePageDataDeliveryBaseUrl({ H5_PUBLIC_BASE_URL: 'http://127.0.0.1:5173', H5_PORT: '8081' }), + 'http://127.0.0.1:8081', + ); + assert.equal( + resolvePageDataDeliveryBaseUrl({ + MEMIND_PAGE_DATA_DELIVERY_BASE_URL: 'http://127.0.0.1:8081', + H5_PUBLIC_BASE_URL: 'http://127.0.0.1:5173', + }), + 'http://127.0.0.1:8081', + ); + assert.equal( + resolvePageDataDeliveryBaseUrl({ H5_PUBLIC_BASE_URL: 'https://m.tkmind.cn' }), + 'https://m.tkmind.cn', + ); +});