Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 249b0697cb | |||
| cdeb24ba92 | |||
| fb055c6721 | |||
| da422a82e6 | |||
| 7005b501af | |||
| 502334777e | |||
| 16fd99e8ba | |||
| 2f7bc3b6e4 | |||
| ca4805533b | |||
| cef3750ac6 | |||
| 67d529c81e |
@@ -28,6 +28,7 @@ bash scripts/check-release-ready.sh
|
||||
|------|------|
|
||||
| MindSpace 公开页 `edit_file` 落盘 + 聊天 Finish 不丢消息 | [docs/regression-guards/mindspace-publish-and-chat-finish.md](docs/regression-guards/mindspace-publish-and-chat-finish.md) |
|
||||
| MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) |
|
||||
| Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) |
|
||||
|
||||
索引:[docs/regression-guards/README.md](docs/regression-guards/README.md)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
|
||||
const PAGE_DATA_INTENT_PATTERNS = [
|
||||
/(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u,
|
||||
/(?:报名表|报名登记|在线报名|收集报名)/u,
|
||||
/(?:作业登记|作业打卡|作业记录|每日作业|打卡登记|台账|记录表)/u,
|
||||
/(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u,
|
||||
/(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u,
|
||||
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|------|----------|
|
||||
| [mindspace-publish-and-chat-finish.md](./mindspace-publish-and-chat-finish.md) | ① `edit_file` 覆盖 `public/*.html` ② Finish 后聊天不清空、不暴露 agent 内部前缀 |
|
||||
| [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML |
|
||||
| [page-data-delivery-contract.md](./page-data-delivery-contract.md) | 数据集注册、绑定、真实 page UUID 与交付验收 |
|
||||
|
||||
## 自动化
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Page Data 交付契约
|
||||
|
||||
Page Data 页面只有在下面所有条件满足后才可向用户交付链接:
|
||||
|
||||
1. HTML 实际调用的每个 dataset 已在工作区 `.mindspace/private-data.sqlite` 注册。
|
||||
2. 每个注册 dataset 的真实 SQLite 表、声明字段和读写 action 都存在。
|
||||
3. page record、online publication 与 policy 使用同一个真实 page UUID。
|
||||
4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。
|
||||
|
||||
`private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。
|
||||
|
||||
运行时路径必须按语义区分:
|
||||
|
||||
- `workspaceRoot`:`MindSpace/<user>`,保存 HTML、policy 和 private-data.sqlite。
|
||||
- `storageRoot`:MindSpace service 的持久页面/资产存储。
|
||||
- `usersRoot`:登录用户目录。
|
||||
|
||||
不要通过 `MINDSPACE_STORAGE_ROOT` 推断 Page Data 的 workspaceRoot。Portal、MindSpace service 与 sandbox MCP 必须显式使用同一 workspace contract。
|
||||
|
||||
回归命令:
|
||||
|
||||
```bash
|
||||
npm run verify:page-data
|
||||
npm run verify:mindspace-publish-guards:full
|
||||
npm run verify:mindspace-page-sync-guards
|
||||
```
|
||||
|
||||
涉及 H5 交付时,还必须验证:未注册 dataset 时不产生可用 Page Data policy,且最终链接交付被拒绝或进入明确 repair 状态。
|
||||
@@ -234,7 +234,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
|
||||
const before = evaluatePageDataFinishGuard({
|
||||
publishDir: workspaceRoot,
|
||||
agentText: SURVEY_USER_TEXT,
|
||||
messages: [],
|
||||
messages: [{
|
||||
createdAt: Date.now(),
|
||||
content: [{
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
assert.equal(before.unboundFiles.length, 1);
|
||||
|
||||
@@ -246,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',
|
||||
@@ -264,7 +270,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
|
||||
const after = evaluatePageDataFinishGuard({
|
||||
publishDir: workspaceRoot,
|
||||
agentText: SURVEY_USER_TEXT,
|
||||
messages: [],
|
||||
messages: [{
|
||||
createdAt: Date.now(),
|
||||
content: [{
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
assert.equal(after.unboundFiles.length, 0);
|
||||
assert.equal(after.htmlIssues.length, 0);
|
||||
@@ -285,7 +297,13 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
|
||||
sessionId: 'session-h5-page-data',
|
||||
userId: 'user-page-data',
|
||||
publishDir: workspaceRoot,
|
||||
messages: [],
|
||||
messages: [{
|
||||
createdAt: Date.now(),
|
||||
content: [{
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } },
|
||||
}],
|
||||
}],
|
||||
pool: null,
|
||||
h5Root: workspaceRoot,
|
||||
storageRoot: workspaceRoot,
|
||||
@@ -307,3 +325,28 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('integration: finish guard does not repair historical 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'), VALID_SURVEY_HTML, 'utf8');
|
||||
const result = await maybeRepairPageDataAfterFinish({
|
||||
sessionId: 'session-with-history',
|
||||
userId: 'user-page-data',
|
||||
publishDir: workspaceRoot,
|
||||
messages: [],
|
||||
pool: null,
|
||||
h5Root: workspaceRoot,
|
||||
storageRoot: workspaceRoot,
|
||||
userText: '确认发布',
|
||||
tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } },
|
||||
});
|
||||
assert.equal(result.structuralPageData, false);
|
||||
assert.equal(result.relevantFiles.length, 0);
|
||||
assert.equal(result.needsRepair, false);
|
||||
assert.equal(result.triggered, undefined);
|
||||
} finally {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@ 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,
|
||||
normalizePageDataApiBase,
|
||||
verifyPageDataDeliveryArtifacts,
|
||||
} from './page-data-delivery-assess.mjs';
|
||||
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
|
||||
@@ -228,7 +229,8 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } =
|
||||
if (item?.type !== 'toolRequest') continue;
|
||||
const toolCall = item.toolCall?.value;
|
||||
const name = String(toolCall?.name ?? '').trim();
|
||||
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue;
|
||||
const normalizedName = name.split('__').at(-1);
|
||||
if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue;
|
||||
const args = toolCall?.arguments ?? {};
|
||||
const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
|
||||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||||
@@ -239,18 +241,66 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } =
|
||||
return [...targets];
|
||||
}
|
||||
|
||||
export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } = {}) {
|
||||
const targets = new Set();
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
const createdAt = Number(message?.created ?? message?.createdAt ?? 0);
|
||||
if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue;
|
||||
for (const item of message?.content ?? []) {
|
||||
if (item?.type !== 'toolRequest') continue;
|
||||
const toolCall = item.toolCall?.value;
|
||||
const name = String(toolCall?.name ?? '').trim().split('__').at(-1);
|
||||
if (name !== 'private_data_bind_workspace_page') continue;
|
||||
const candidate = String(toolCall?.arguments?.relativePath ?? '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/');
|
||||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||||
targets.add(candidate.startsWith('public/') ? candidate : `public/${path.posix.basename(candidate)}`);
|
||||
}
|
||||
}
|
||||
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 recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt });
|
||||
const relevantPaths = new Set([...recentWrites, ...recentBinds]);
|
||||
// A reused session/workspace can contain many historical Page Data pages.
|
||||
// Only files touched or explicitly bound in this request belong to this
|
||||
// delivery. Scanning the whole workspace lets one stale localStorage page
|
||||
// incorrectly block every later, correctly bound survey.
|
||||
const relevantFiles = pageDataFiles.filter((file) => relevantPaths.has(file.relativePath));
|
||||
const structuralFiles = relevantFiles.filter(isStructuralPageDataHtmlFile);
|
||||
|
||||
const htmlIssues = relevantFiles.flatMap((file) =>
|
||||
file.evaluation.issues.map((issue) => ({
|
||||
@@ -273,10 +323,13 @@ export function evaluatePageDataFinishGuard({
|
||||
pageDataIntent &&
|
||||
(htmlIssues.length > 0 ||
|
||||
unboundFiles.length > 0 ||
|
||||
(relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages)));
|
||||
(relevantFiles.length === 0 &&
|
||||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||||
usedPageDataCollectSkill(messages)));
|
||||
|
||||
return {
|
||||
pageDataIntent,
|
||||
structuralPageData: structuralFiles.length > 0,
|
||||
relevantFiles,
|
||||
htmlIssues,
|
||||
unboundFiles,
|
||||
@@ -309,10 +362,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)));
|
||||
|
||||
@@ -402,16 +456,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 };
|
||||
@@ -433,12 +486,10 @@ export async function resolvePageDataCollectOutcomeAsync({
|
||||
pool = null,
|
||||
userId = null,
|
||||
findPageByRelativePath = null,
|
||||
apiBase = null,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||
if (!isPageDataIntent(agentText)) {
|
||||
return { action: 'skip' };
|
||||
}
|
||||
|
||||
const evaluation = await evaluatePageDataFinishGuardAsync({
|
||||
publishDir,
|
||||
agentText,
|
||||
@@ -448,10 +499,45 @@ 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 };
|
||||
}
|
||||
|
||||
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,
|
||||
publishDir,
|
||||
apiBase,
|
||||
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 (evaluation.unboundFiles.length > 0) {
|
||||
return { action: 'retry', reason: 'missing_bind', evaluation };
|
||||
}
|
||||
@@ -479,66 +565,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({
|
||||
@@ -558,7 +593,7 @@ export async function ensurePageDataDeliveryReady({
|
||||
const failures = await verifyPageDataDeliveryArtifacts({
|
||||
artifacts,
|
||||
publishDir,
|
||||
apiBase,
|
||||
apiBase: normalizePageDataApiBase(apiBase),
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
@@ -585,14 +620,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 };
|
||||
}
|
||||
@@ -603,13 +638,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,
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
collectPageDataDeliveryArtifacts,
|
||||
evaluatePageDataFinishGuard,
|
||||
evaluatePageDataHtmlContent,
|
||||
extractRecentPageDataBindTargets,
|
||||
extractRecentPageDataHtmlWrites,
|
||||
inferPageDataBindAccessMode,
|
||||
maybeAutoBindPageDataHtmlPages,
|
||||
rewritePageDataDeliveryLinks,
|
||||
shouldRetryPageDataCollectReply,
|
||||
} from './mindspace-page-data-finish-guard.mjs';
|
||||
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
|
||||
const SURVEY_HTML = `<!doctype html><html><head><title>问卷</title></head><body>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
@@ -57,7 +60,7 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => {
|
||||
const evaluation = evaluatePageDataFinishGuard({
|
||||
publishDir,
|
||||
agentText: '帮我设计一个调查问卷,要加一个后台',
|
||||
messages: [],
|
||||
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
|
||||
});
|
||||
assert.equal(evaluation.pageDataIntent, true);
|
||||
assert.equal(evaluation.unboundFiles.length, 1);
|
||||
@@ -67,6 +70,51 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('finish guard ignores unrelated historical broken Page Data html', async () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-history-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publishDir, 'public', 'historical-admin.html'), BAD_SURVEY_HTML, 'utf8');
|
||||
fs.writeFileSync(path.join(publishDir, 'public', 'current-survey.html'), SURVEY_HTML, 'utf8');
|
||||
const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir });
|
||||
await dataSpace.executeSql('CREATE TABLE diet_survey (id INTEGER PRIMARY KEY AUTOINCREMENT);');
|
||||
await dataSpace.upsertDataset({
|
||||
name: 'diet_survey',
|
||||
table: 'diet_survey',
|
||||
actions: ['read', 'insert'],
|
||||
columns: { read: ['id'], insert: [] },
|
||||
});
|
||||
writePageAccessPolicy(publishDir, {
|
||||
pageId: 'current-page',
|
||||
ownerUserId: 'user-1',
|
||||
accessMode: 'public',
|
||||
datasets: { diet_survey: { insert: true, read: false, columns: { insert: [] } } },
|
||||
});
|
||||
const evaluation = evaluatePageDataFinishGuard({
|
||||
publishDir,
|
||||
agentText: '调查问卷和后台',
|
||||
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: { path: 'public/current-survey.html' },
|
||||
} } }] }],
|
||||
});
|
||||
assert.deepEqual(evaluation.relevantFiles.map((file) => file.relativePath), ['public/current-survey.html']);
|
||||
assert.deepEqual(evaluation.htmlIssues, []);
|
||||
assert.deepEqual(evaluation.unboundFiles, []);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('recent Page Data target extraction supports namespaced writes and bind-only delivery', () => {
|
||||
const messages = [{ content: [
|
||||
{ type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/form.html' } } } },
|
||||
{ type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__private_data_bind_workspace_page', arguments: { relativePath: 'public/form-admin.html' } } } },
|
||||
] }];
|
||||
assert.deepEqual(extractRecentPageDataHtmlWrites(messages), ['public/form.html']);
|
||||
assert.deepEqual(extractRecentPageDataBindTargets(messages), ['public/form-admin.html']);
|
||||
});
|
||||
|
||||
test('shouldRetryPageDataCollectReply retries when localStorage fallback exists', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-retry-'));
|
||||
try {
|
||||
@@ -74,7 +122,7 @@ test('shouldRetryPageDataCollectReply retries when localStorage fallback exists'
|
||||
fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
|
||||
assert.equal(
|
||||
shouldRetryPageDataCollectReply({
|
||||
reply: { text: '问卷已发布', messages: [] },
|
||||
reply: { text: '问卷已发布', messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } } }] }] },
|
||||
intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
|
||||
publishDir,
|
||||
}),
|
||||
@@ -108,7 +156,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 });
|
||||
}
|
||||
@@ -134,7 +182,7 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', ()
|
||||
const evaluation = evaluatePageDataFinishGuard({
|
||||
publishDir,
|
||||
agentText: '调查问卷和后台',
|
||||
messages: [],
|
||||
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
|
||||
});
|
||||
assert.equal(evaluation.unboundFiles.length, 1);
|
||||
assert.equal(evaluation.htmlIssues.length, 0);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* WeChat WebView compatibility patches for Page Data survey HTML.
|
||||
* X5 often fails to select radios hidden with pointer-events:none.
|
||||
*/
|
||||
|
||||
export function htmlNeedsWechatSurveyCompat(html) {
|
||||
const content = String(html ?? '');
|
||||
if (!/\/assets\/page-data-client\.js/i.test(content)) return false;
|
||||
if (!/\.insertRow\s*\(/.test(content)) return false;
|
||||
return (
|
||||
/pointer-events\s*:\s*none/i.test(content) ||
|
||||
/\.genre-option\s+input/i.test(content) ||
|
||||
/input\[type=["']radio["']\][^{]*pointer-events/i.test(content)
|
||||
);
|
||||
}
|
||||
|
||||
export function applyWechatSurveyCompat(html) {
|
||||
const source = String(html ?? '');
|
||||
if (!htmlNeedsWechatSurveyCompat(source)) {
|
||||
return { html: source, patched: false };
|
||||
}
|
||||
|
||||
let next = source;
|
||||
|
||||
next = next.replace(
|
||||
/\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none\s*;?[^}]*\}/gi,
|
||||
'.genre-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 2; }',
|
||||
);
|
||||
|
||||
if (!/data-mindspace-wechat-survey-compat/i.test(next)) {
|
||||
const patch = `<style id="mindspace-wechat-survey-compat">
|
||||
.genre-option { position: relative; }
|
||||
.genre-option label { position: relative; z-index: 1; pointer-events: none; -webkit-tap-highlight-color: transparent; }
|
||||
.genre-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 2; }
|
||||
.genre-option input:checked + label { pointer-events: none; }
|
||||
button.submit-btn, .submit-btn { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
|
||||
</style>`;
|
||||
if (/<\/head>/i.test(next)) {
|
||||
next = next.replace(/<\/head>/i, `${patch}\n</head>`);
|
||||
} else if (/<body\b/i.test(next)) {
|
||||
next = next.replace(/<body\b/i, `${patch}\n<body`);
|
||||
} else {
|
||||
next = `${patch}\n${next}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { html: next, patched: true };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyWechatSurveyCompat,
|
||||
htmlNeedsWechatSurveyCompat,
|
||||
} from './mindspace-page-data-wechat-survey-compat.mjs';
|
||||
|
||||
const SURVEY_HTML = `<!doctype html><html><head><style>
|
||||
.genre-option input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
<script src="/assets/page-data-client.js"></script></head><body>
|
||||
<script>MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('reading_survey', {});</script>
|
||||
</body></html>`;
|
||||
|
||||
test('htmlNeedsWechatSurveyCompat detects pointer-events none on survey radios', () => {
|
||||
assert.equal(htmlNeedsWechatSurveyCompat(SURVEY_HTML), true);
|
||||
assert.equal(htmlNeedsWechatSurveyCompat('<html><body>plain</body></html>'), false);
|
||||
});
|
||||
|
||||
test('applyWechatSurveyCompat removes pointer-events none from radio inputs', () => {
|
||||
const result = applyWechatSurveyCompat(SURVEY_HTML);
|
||||
assert.equal(result.patched, true);
|
||||
assert.doesNotMatch(result.html, /\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none/i);
|
||||
assert.match(result.html, /mindspace-wechat-survey-compat/);
|
||||
assert.match(result.html, /z-index:\s*2/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { injectMindSpacePageDataContext } from './mindspace-public-page-context.mjs';
|
||||
import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs';
|
||||
import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs';
|
||||
import { applyWechatSurveyCompat } from './mindspace-page-data-wechat-survey-compat.mjs';
|
||||
|
||||
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
|
||||
@@ -97,6 +98,9 @@ export function decorateMindSpacePublishedHtml({
|
||||
if (pageDataContext?.pageId) {
|
||||
nextHtml = injectMindSpacePageDataContext(nextHtml, pageDataContext);
|
||||
}
|
||||
if (!embed && isWechatUserAgent(userAgent || '')) {
|
||||
nextHtml = applyWechatSurveyCompat(nextHtml).html;
|
||||
}
|
||||
const scriptHashes = collectInlineScriptHashes(nextHtml);
|
||||
return {
|
||||
html: nextHtml,
|
||||
|
||||
@@ -21,7 +21,14 @@ export function publishedPageCsp(
|
||||
return publishedPageCspForEmbed(true);
|
||||
}
|
||||
if (wechatShare && isFullHtml) {
|
||||
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
|
||||
const scriptSrc = scriptSrcDirective({
|
||||
inline: true,
|
||||
urls: [
|
||||
...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []),
|
||||
'https://res.wx.qq.com',
|
||||
],
|
||||
});
|
||||
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`;
|
||||
}
|
||||
if (raw && isFullHtml) {
|
||||
const scriptSrc = scriptSrcDirective({
|
||||
|
||||
@@ -23,3 +23,15 @@ test('publishedPageCsp non-raw full html allows self when external scripts are p
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: false });
|
||||
assert.match(csp, /script-src 'self'/);
|
||||
});
|
||||
|
||||
test('publishedPageCsp wechatShare mode allows self when page-data-client.js is referenced', () => {
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML, { wechatShare: true });
|
||||
assert.match(csp, /script-src 'unsafe-inline' 'self' https:\/\/res\.wx\.qq\.com/);
|
||||
});
|
||||
|
||||
test('publishedPageCsp wechatShare mode keeps inline-only pages without self', () => {
|
||||
const html = '<!doctype html><html><body><script>console.log(1)</script></body></html>';
|
||||
const csp = publishedPageCsp(html, { wechatShare: true });
|
||||
assert.match(csp, /script-src 'unsafe-inline' https:\/\/res\.wx\.qq\.com/);
|
||||
assert.doesNotMatch(csp, /script-src 'unsafe-inline' 'self'/);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,14 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MEMIND_ROOT="${MEMIND_SHARED_ROOT:-/Users/john/Project/Memind}"
|
||||
|
||||
# The standalone MindSpace service may read shared Memind secrets from the
|
||||
# Portal env, but its own data roots must stay owned by the standalone service
|
||||
# root. Preserve explicitly inherited standalone root overrides, then clear any
|
||||
# values that may be sourced from the monolith env below.
|
||||
EXPLICIT_MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT-}"
|
||||
EXPLICIT_MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT-}"
|
||||
EXPLICIT_H5_USERS_ROOT="${H5_USERS_ROOT-}"
|
||||
|
||||
if [[ -f "${MEMIND_ROOT}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
@@ -11,11 +19,38 @@ if [[ -f "${MEMIND_ROOT}/.env" ]]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}" ]]; then
|
||||
unset MINDSPACE_SERVICE_H5_ROOT
|
||||
fi
|
||||
if [[ -z "${EXPLICIT_MINDSPACE_STORAGE_ROOT}" ]]; then
|
||||
unset MINDSPACE_STORAGE_ROOT
|
||||
fi
|
||||
if [[ -z "${EXPLICIT_H5_USERS_ROOT}" ]]; then
|
||||
unset H5_USERS_ROOT
|
||||
fi
|
||||
|
||||
if [[ -f "${ROOT}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -n "${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}" ]]; then
|
||||
export MINDSPACE_SERVICE_H5_ROOT="${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}"
|
||||
fi
|
||||
if [[ -n "${EXPLICIT_MINDSPACE_STORAGE_ROOT}" ]]; then
|
||||
export MINDSPACE_STORAGE_ROOT="${EXPLICIT_MINDSPACE_STORAGE_ROOT}"
|
||||
fi
|
||||
if [[ -n "${EXPLICIT_H5_USERS_ROOT}" ]]; then
|
||||
export H5_USERS_ROOT="${EXPLICIT_H5_USERS_ROOT}"
|
||||
fi
|
||||
|
||||
export NODE_ENV=production
|
||||
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}"
|
||||
export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}"
|
||||
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${MEMIND_ROOT}/data/mindspace}"
|
||||
export H5_USERS_ROOT="${H5_USERS_ROOT:-${MEMIND_ROOT}/users}"
|
||||
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}"
|
||||
export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}"
|
||||
export MINDSPACE_SERVICE_PORT="${MINDSPACE_SERVICE_PORT:-8082}"
|
||||
export MINDSPACE_SERVICE_HOST="${MINDSPACE_SERVICE_HOST:-127.0.0.1}"
|
||||
export MINDSPACE_REMOTE_AUTH_TOKEN="${MINDSPACE_REMOTE_AUTH_TOKEN:-${TKMIND_SERVER__SECRET_KEY:-}}"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -7,10 +7,17 @@ import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
|
||||
const INJECTION_PAGE_ID_PATTERN =
|
||||
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*"pageId"\s*:\s*"([^"]+)"/i;
|
||||
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*(?:"pageId"|pageId)\s*:\s*"([^"]+)"/i;
|
||||
const INJECTION_META_PAGE_ID_PATTERN =
|
||||
/<meta[^>]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i;
|
||||
|
||||
export function normalizePageDataApiBase(apiBase) {
|
||||
const raw = String(apiBase ?? '').trim().replace(/\/$/, '');
|
||||
if (!raw) return '/api';
|
||||
if (/\/api$/i.test(raw)) return raw;
|
||||
return `${raw}/api`;
|
||||
}
|
||||
|
||||
export function extractInjectedPageIdFromHtml(html) {
|
||||
const content = String(html ?? '');
|
||||
const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim();
|
||||
@@ -140,10 +147,6 @@ export async function assessPageDataHtmlBinding({
|
||||
}
|
||||
}
|
||||
|
||||
const publication = await queryOnlinePublication(pool, pageId);
|
||||
if (!publication) {
|
||||
reasons.push('missing_online_publication');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -179,7 +182,7 @@ export async function smokeTestPageDataInsert({
|
||||
policy,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const base = String(apiBase ?? '').replace(/\/$/, '') || '/api';
|
||||
const base = normalizePageDataApiBase(apiBase);
|
||||
const row = buildSmokeInsertRow(policy, dataset);
|
||||
if (!pageId || !dataset || !Object.keys(row).length) {
|
||||
return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null };
|
||||
@@ -229,6 +232,29 @@ export async function verifyPageDataDeliveryArtifacts({
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageId = injection.pageId;
|
||||
const policy =
|
||||
readPageAccessPolicy(publishDir, pageId) ??
|
||||
(await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath,
|
||||
html,
|
||||
findPageByRelativePath,
|
||||
})).policy;
|
||||
|
||||
const smoke = await smokeTestPageDataInsert({
|
||||
apiBase,
|
||||
pageId,
|
||||
dataset: insertDataset,
|
||||
policy,
|
||||
fetchImpl,
|
||||
});
|
||||
if (smoke.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
@@ -237,30 +263,13 @@ export async function verifyPageDataDeliveryArtifacts({
|
||||
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,
|
||||
failures.push({
|
||||
relativePath,
|
||||
stage: 'insert_smoke',
|
||||
reason: smoke.reason,
|
||||
status: smoke.status,
|
||||
bindingReasons: assessment.reasons,
|
||||
});
|
||||
if (!smoke.ok) {
|
||||
failures.push({
|
||||
relativePath,
|
||||
stage: 'insert_smoke',
|
||||
reason: smoke.reason,
|
||||
status: smoke.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildSmokeInsertRow,
|
||||
extractInjectedPageIdFromHtml,
|
||||
normalizePageDataApiBase,
|
||||
} from './page-data-delivery-assess.mjs';
|
||||
|
||||
test('extractInjectedPageIdFromHtml reads injected pageId', () => {
|
||||
@@ -13,6 +14,18 @@ test('extractInjectedPageIdFromHtml reads injected pageId', () => {
|
||||
assert.equal(extractInjectedPageIdFromHtml(html), 'page-abc');
|
||||
});
|
||||
|
||||
test('extractInjectedPageIdFromHtml reads unquoted pageId keys', () => {
|
||||
const html =
|
||||
'<script>window.__MINDSPACE_PAGE_DATA__={pageId:"page-unquoted",accessMode:"public"};</script>';
|
||||
assert.equal(extractInjectedPageIdFromHtml(html), 'page-unquoted');
|
||||
});
|
||||
|
||||
test('normalizePageDataApiBase appends /api to public base url', () => {
|
||||
assert.equal(normalizePageDataApiBase('https://m.tkmind.cn'), 'https://m.tkmind.cn/api');
|
||||
assert.equal(normalizePageDataApiBase('https://m.tkmind.cn/api'), 'https://m.tkmind.cn/api');
|
||||
assert.equal(normalizePageDataApiBase('/api'), '/api');
|
||||
});
|
||||
|
||||
test('buildSmokeInsertRow fills insert columns', () => {
|
||||
const row = buildSmokeInsertRow(
|
||||
{
|
||||
|
||||
@@ -17,16 +17,29 @@ export function htmlUsesPageDataApi(html) {
|
||||
export function detectPageDataDatasetUsageFromHtml(html) {
|
||||
const text = String(html ?? '');
|
||||
const datasets = new Map();
|
||||
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]);
|
||||
}
|
||||
|
||||
function remember(name, patch) {
|
||||
const key = String(name ?? '').trim();
|
||||
if (!key) return;
|
||||
datasets.set(key, { ...(datasets.get(key) ?? {}), ...patch });
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) {
|
||||
const name = match[1];
|
||||
const prev = datasets.get(name) ?? {};
|
||||
datasets.set(name, { ...prev, insert: true });
|
||||
remember(match[1], { insert: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
|
||||
const name = match[1];
|
||||
const prev = datasets.get(name) ?? {};
|
||||
datasets.set(name, { ...prev, read: true });
|
||||
remember(match[1], { read: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
remember(constants.get(match[1]) ?? match[1], { insert: true });
|
||||
}
|
||||
for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
remember(constants.get(match[1]) ?? match[1], { read: true });
|
||||
}
|
||||
|
||||
return datasets;
|
||||
|
||||
@@ -16,6 +16,15 @@ test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () =>
|
||||
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true });
|
||||
});
|
||||
|
||||
test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => {
|
||||
const html = `
|
||||
const DATASET = 'reading_survey';
|
||||
await client.listRows(DATASET, { limit: 500 });
|
||||
`;
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
assert.deepEqual(usage.get('reading_survey'), { read: true });
|
||||
});
|
||||
|
||||
test('assertPolicyMatchesHtmlDatasets rejects mismatched dataset names', () => {
|
||||
const html = `await c.insertRow('tkmind_exp_survey', {});`;
|
||||
assert.throws(
|
||||
|
||||
@@ -135,6 +135,84 @@ function buildWorkspacePublicUrl(userId, relativePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) {
|
||||
for (const dataset of registryDatasets) {
|
||||
const requiredUsage = usage.get(dataset.name) ?? {};
|
||||
if (requiredUsage.insert && !dataset.actions.includes('insert')) {
|
||||
throw Object.assign(new Error(`dataset「${dataset.name}」未开放写入`), {
|
||||
code: 'dataset_action_not_registered',
|
||||
datasetName: dataset.name,
|
||||
action: 'insert',
|
||||
});
|
||||
}
|
||||
if (requiredUsage.read && !dataset.actions.includes('read')) {
|
||||
throw Object.assign(new Error(`dataset「${dataset.name}」未开放读取`), {
|
||||
code: 'dataset_action_not_registered',
|
||||
datasetName: dataset.name,
|
||||
action: 'read',
|
||||
});
|
||||
}
|
||||
const actualColumns = userDataSpace.listTableColumns(dataset.table);
|
||||
if (!actualColumns.length) {
|
||||
throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), {
|
||||
code: 'table_not_found',
|
||||
datasetName: dataset.name,
|
||||
});
|
||||
}
|
||||
const actualNames = new Set(actualColumns.map((column) => column.name));
|
||||
const configuredColumns = Object.values(dataset.columns ?? {}).flat();
|
||||
const missingColumns = [...new Set(configuredColumns)].filter((column) => !actualNames.has(column));
|
||||
if (missingColumns.length) {
|
||||
throw Object.assign(
|
||||
new Error(`dataset「${dataset.name}」注册字段不存在:${missingColumns.join(', ')}`),
|
||||
{ code: 'dataset_schema_mismatch', datasetName: dataset.name, missingColumns },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page Data policy must be derived from a registered SQLite dataset, never
|
||||
* accepted solely from an Agent-supplied policy. This runs before page or
|
||||
* publication creation so an incomplete data setup cannot leave a live page.
|
||||
*/
|
||||
export function resolveRegisteredPageDataPolicy({
|
||||
html,
|
||||
userId,
|
||||
accessMode,
|
||||
pageDataPolicy = null,
|
||||
userDataSpace,
|
||||
} = {}) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
if (!usage.size) return null;
|
||||
if (!userDataSpace) throw new Error('缺少 Page Data 数据空间');
|
||||
|
||||
if (pageDataPolicy?.datasets) {
|
||||
assertPolicyMatchesHtmlDatasets(html, pageDataPolicy.datasets);
|
||||
}
|
||||
|
||||
const registryDatasets = userDataSpace.listDatasets();
|
||||
const datasets = buildPageDataPolicyDatasetsFromRegistry({
|
||||
html,
|
||||
registryDatasets,
|
||||
usage,
|
||||
});
|
||||
assertRegisteredDatasetTables(
|
||||
userDataSpace,
|
||||
registryDatasets.filter((dataset) => datasets[dataset.name]),
|
||||
usage,
|
||||
);
|
||||
|
||||
return {
|
||||
...pageDataPolicy,
|
||||
ownerUserId: String(pageDataPolicy?.ownerUserId ?? userId).trim(),
|
||||
accessMode: pageDataPolicy?.accessMode ?? accessMode,
|
||||
// The registry is authoritative for actions and allowed columns. Agent
|
||||
// input can describe the page, but cannot manufacture a dataset policy.
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bindWorkspaceHtmlForPageData({
|
||||
pool,
|
||||
h5Root,
|
||||
@@ -158,6 +236,15 @@ export async function bindWorkspaceHtmlForPageData({
|
||||
const normalizedAccessMode = resolvePageDataBindAccess(accessMode);
|
||||
const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password);
|
||||
|
||||
const userDataSpace = createUserDataSpaceService({ workspaceRoot });
|
||||
const resolvedPageDataPolicy = resolveRegisteredPageDataPolicy({
|
||||
html: content,
|
||||
userId,
|
||||
accessMode: normalizedAccessMode,
|
||||
pageDataPolicy,
|
||||
userDataSpace,
|
||||
});
|
||||
|
||||
const mindSpacePages = createPageService(pool, { h5Root, storageRoot });
|
||||
const mindSpacePublications = createPublicationService(pool, {
|
||||
h5Root,
|
||||
@@ -183,26 +270,6 @@ export async function bindWorkspaceHtmlForPageData({
|
||||
});
|
||||
|
||||
let policy = null;
|
||||
const htmlDatasetUsage = detectPageDataDatasetUsageFromHtml(content);
|
||||
let resolvedPageDataPolicy = pageDataPolicy;
|
||||
|
||||
if (htmlDatasetUsage.size) {
|
||||
if (pageDataPolicy?.datasets) {
|
||||
assertPolicyMatchesHtmlDatasets(content, pageDataPolicy.datasets);
|
||||
} else {
|
||||
const userDataSpace = createUserDataSpaceService({ workspaceRoot });
|
||||
const autoDatasets = buildPageDataPolicyDatasetsFromRegistry({
|
||||
html: content,
|
||||
registryDatasets: userDataSpace.listDatasets(),
|
||||
usage: htmlDatasetUsage,
|
||||
});
|
||||
resolvedPageDataPolicy = {
|
||||
ownerUserId: userId,
|
||||
accessMode: normalizedAccessMode,
|
||||
datasets: autoDatasets,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPageDataPolicy) {
|
||||
const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim();
|
||||
|
||||
@@ -4,8 +4,18 @@ import {
|
||||
DEFAULT_PAGE_DATA_ADMIN_PASSWORD,
|
||||
resolvePageDataBindAccess,
|
||||
resolvePageDataBindPassword,
|
||||
resolveRegisteredPageDataPolicy,
|
||||
} from './page-data-workspace-bind.mjs';
|
||||
|
||||
const HTML = '<script src="/assets/page-data-client.js"></script><script>MindSpacePageData.createClient().insertRow("activity_signups", { name: "test" })</script>';
|
||||
|
||||
function createDataSpace({ datasets = [], tables = {} } = {}) {
|
||||
return {
|
||||
listDatasets: () => datasets,
|
||||
listTableColumns: (table) => tables[table] ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
test('resolvePageDataBindPassword defaults for password mode', () => {
|
||||
assert.equal(resolvePageDataBindPassword('password', null), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
|
||||
assert.equal(resolvePageDataBindPassword('password', ''), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
|
||||
@@ -32,3 +42,82 @@ test('resolvePageDataBindAccess normalizes access mode', () => {
|
||||
assert.equal(resolvePageDataBindAccess('public'), 'public');
|
||||
assert.equal(resolvePageDataBindAccess('password'), 'password');
|
||||
});
|
||||
|
||||
test('binding rejects an Agent policy when its HTML dataset is not registered', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveRegisteredPageDataPolicy({
|
||||
html: HTML,
|
||||
userId: 'user-1',
|
||||
accessMode: 'public',
|
||||
pageDataPolicy: { datasets: { activity_signups: { insert: true } } },
|
||||
userDataSpace: createDataSpace(),
|
||||
}),
|
||||
(error) => error.code === 'dataset_not_registered',
|
||||
);
|
||||
});
|
||||
|
||||
test('binding derives policy from the registered dataset and rejects a missing table', () => {
|
||||
const dataset = {
|
||||
name: 'activity_signups',
|
||||
table: 'activity_signups',
|
||||
actions: ['insert'],
|
||||
columns: { insert: ['name'] },
|
||||
limits: {},
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveRegisteredPageDataPolicy({
|
||||
html: HTML,
|
||||
userId: 'user-1',
|
||||
accessMode: 'public',
|
||||
pageDataPolicy: { datasets: { activity_signups: { insert: true, columns: { insert: ['forged'] } } } },
|
||||
userDataSpace: createDataSpace({ datasets: [dataset] }),
|
||||
}),
|
||||
(error) => error.code === 'table_not_found',
|
||||
);
|
||||
});
|
||||
|
||||
test('binding uses registered columns instead of Agent-supplied policy columns', () => {
|
||||
const dataset = {
|
||||
name: 'activity_signups',
|
||||
table: 'activity_signups',
|
||||
actions: ['insert'],
|
||||
columns: { insert: ['name'] },
|
||||
limits: {},
|
||||
};
|
||||
const policy = resolveRegisteredPageDataPolicy({
|
||||
html: HTML,
|
||||
userId: 'user-1',
|
||||
accessMode: 'public',
|
||||
pageDataPolicy: { datasets: { activity_signups: { insert: true, columns: { insert: ['forged'] } } } },
|
||||
userDataSpace: createDataSpace({
|
||||
datasets: [dataset],
|
||||
tables: { activity_signups: [{ name: 'id' }, { name: 'name' }] },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(policy.datasets.activity_signups.columns.insert, ['name']);
|
||||
});
|
||||
|
||||
test('binding rejects a registered dataset that does not allow the HTML action', () => {
|
||||
const dataset = {
|
||||
name: 'activity_signups',
|
||||
table: 'activity_signups',
|
||||
actions: ['read'],
|
||||
columns: { insert: ['name'], read: ['id', 'name'] },
|
||||
limits: {},
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveRegisteredPageDataPolicy({
|
||||
html: HTML,
|
||||
userId: 'user-1',
|
||||
accessMode: 'public',
|
||||
userDataSpace: createDataSpace({
|
||||
datasets: [dataset],
|
||||
tables: { activity_signups: [{ name: 'id' }, { name: 'name' }] },
|
||||
}),
|
||||
}),
|
||||
(error) => error.code === 'dataset_action_not_registered' && error.action === 'insert',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -128,7 +128,11 @@ async function writeMetadata() {
|
||||
'',
|
||||
'Production runtime target:',
|
||||
' /Users/john/MindSpace',
|
||||
'Shared persisted data root:',
|
||||
'Standalone persisted data root:',
|
||||
' /Users/john/MindSpace/data/mindspace',
|
||||
'Standalone users root:',
|
||||
' /Users/john/MindSpace/users',
|
||||
'Shared Portal/H5 root:',
|
||||
' /Users/john/Project/Memind',
|
||||
'',
|
||||
'This artifact contains:',
|
||||
@@ -145,7 +149,8 @@ async function writeMetadata() {
|
||||
' users/ runtime data',
|
||||
' .env / .env.local',
|
||||
'',
|
||||
'The production service keeps using shared persisted paths from /Users/john/Project/Memind.',
|
||||
'The production service may read shared Portal secrets from /Users/john/Project/Memind/.env,',
|
||||
'but standalone MindSpace data paths must stay under /Users/john/MindSpace.',
|
||||
'',
|
||||
`Git head ref: ${head.trim()}`,
|
||||
].join('\n');
|
||||
|
||||
@@ -279,6 +279,10 @@ cat > "${SERVICE_PLIST}" <<EOF
|
||||
<string>8082</string>
|
||||
<key>MINDSPACE_SERVICE_HOST</key>
|
||||
<string>127.0.0.1</string>
|
||||
<key>MINDSPACE_STORAGE_ROOT</key>
|
||||
<string>${APP_DIR}/data/mindspace</string>
|
||||
<key>H5_USERS_ROOT</key>
|
||||
<string>${APP_DIR}/users</string>
|
||||
<key>MINDSPACE_SERVICE_AGENT_WORKER_ENABLED</key>
|
||||
<string>true</string>
|
||||
<key>MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE</key>
|
||||
@@ -306,6 +310,7 @@ mv "${NEW_DIR}" "${APP_DIR}"
|
||||
APP_MOVED=1
|
||||
|
||||
say "启动 MindSpace service"
|
||||
mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users"
|
||||
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true
|
||||
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 生产 runtime 安全:补建作业登记 Page Data(sqlite + 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」无效)`);
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patch reading-survey HTML on disk for WeChat WebView radio/submit compatibility.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/repair-reading-survey-wechat.mjs
|
||||
* node scripts/repair-reading-survey-wechat.mjs --user a70ff537-8908-486e-9b6c-042e07cc25db
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applyWechatSurveyCompat } from '../mindspace-page-data-wechat-survey-compat.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const userIdx = argv.indexOf('--user');
|
||||
return {
|
||||
userId:
|
||||
userIdx >= 0
|
||||
? String(argv[userIdx + 1] ?? '').trim()
|
||||
: 'a70ff537-8908-486e-9b6c-042e07cc25db',
|
||||
};
|
||||
}
|
||||
|
||||
function patchFile(absolutePath) {
|
||||
const before = fs.readFileSync(absolutePath, 'utf8');
|
||||
const { html, patched } = applyWechatSurveyCompat(before);
|
||||
if (!patched) {
|
||||
console.log(`SKIP ${absolutePath} (no compat patch needed)`);
|
||||
return false;
|
||||
}
|
||||
fs.writeFileSync(absolutePath, html, 'utf8');
|
||||
console.log(`OK ${absolutePath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { userId } = parseArgs(process.argv.slice(2));
|
||||
const base = path.join(root, PUBLISH_ROOT_DIR, userId, 'public');
|
||||
const targets = ['reading-survey.html'];
|
||||
|
||||
let changed = 0;
|
||||
for (const name of targets) {
|
||||
const filePath = path.join(base, name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`MISS ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
if (patchFile(filePath)) changed += 1;
|
||||
}
|
||||
|
||||
console.log(`patched ${changed} file(s)`);
|
||||
process.exit(changed > 0 ? 0 : 1);
|
||||
+6
-1
@@ -136,6 +136,7 @@ import {
|
||||
} from './mindspace-public-finish-sync.mjs';
|
||||
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
|
||||
import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
|
||||
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
|
||||
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
|
||||
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
|
||||
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
|
||||
@@ -463,6 +464,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 +5073,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 +5085,6 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
tkmindProxy,
|
||||
userText: lastUserText,
|
||||
});
|
||||
await syncUserGeneratedPages(uid);
|
||||
};
|
||||
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
|
||||
onAfterFinish,
|
||||
|
||||
+34
-1
@@ -21,6 +21,7 @@ import {
|
||||
} from './wechat/handlers/sync-replies.mjs';
|
||||
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
|
||||
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
|
||||
import { isPageDataIntent } from './chat-skills.mjs';
|
||||
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
||||
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
|
||||
import {
|
||||
@@ -973,6 +974,14 @@ function isTopicResetIntent(text) {
|
||||
return isTopicResetText(text);
|
||||
}
|
||||
|
||||
export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
|
||||
return (
|
||||
wechatIntent?.kind === 'session.reset' ||
|
||||
isTopicResetIntent(resetCandidate) ||
|
||||
isPageDataIntent(resetCandidate)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableWechatAgentSessionError(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
@@ -1065,6 +1074,7 @@ async function enforcePageDataCollectDelivery({
|
||||
pool: pageDataFinishGuard?.pool ?? null,
|
||||
userId,
|
||||
findPageByRelativePath: null,
|
||||
apiBase: publicBaseUrl,
|
||||
});
|
||||
let autoBind = null;
|
||||
if (outcome.action === 'skip') return outcome;
|
||||
@@ -1091,6 +1101,7 @@ async function enforcePageDataCollectDelivery({
|
||||
pool: pageDataFinishGuard.pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
apiBase: publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1905,7 +1916,11 @@ export function createWechatMpService({
|
||||
const wechatIntent = classifyWechatIntent(intent);
|
||||
const resetCandidate =
|
||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||
const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate);
|
||||
// Page Data delivery owns persistent files, datasets and two publication
|
||||
// policies. Reusing a conversational route here can make a new request
|
||||
// inspect/retry unrelated historical pages from that session.
|
||||
const isPageDataRequest = isPageDataIntent(resetCandidate);
|
||||
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
|
||||
let route = await ensureWechatAgentSession({
|
||||
userId: user.userId,
|
||||
openid: inbound.fromUserName,
|
||||
@@ -2064,6 +2079,24 @@ export function createWechatMpService({
|
||||
return { sessionId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// A Page Data request must fail closed. Retrying a poisoned completion in
|
||||
// another session while the finish guard is also active can turn one
|
||||
// request into repairs against historical pages. Drop only this user's
|
||||
// route so the next explicit request starts cleanly.
|
||||
if (isPageDataRequest) {
|
||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||
logger.warn?.('WeChat MP page data route clear failed:', clearErr);
|
||||
});
|
||||
if (!err?.wechatUserNotified) {
|
||||
const text = buildPageDataCollectFailureText();
|
||||
try {
|
||||
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
|
||||
}
|
||||
}
|
||||
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
|
||||
}
|
||||
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
|
||||
if (mayBeStaleSession) {
|
||||
route = await ensureWechatAgentSession({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
loadWechatMpConfig,
|
||||
maybeAttachPublishedHtmlLink,
|
||||
shouldRetryHtmlGenerationReply,
|
||||
shouldForceNewWechatAgentSession,
|
||||
splitWechatText,
|
||||
verifyWechatMpSignature,
|
||||
verifyWechatMpUrlChallenge,
|
||||
@@ -144,6 +145,15 @@ test('splitWechatText respects WeChat 2048-byte customer service limit', () => {
|
||||
assert.equal(chunks.join(''), longChinese);
|
||||
});
|
||||
|
||||
test('Page Data requests always rotate away from an existing WeChat route', () => {
|
||||
assert.equal(
|
||||
shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '做一个问卷,后台可以查看提交数据'),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false);
|
||||
assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true);
|
||||
});
|
||||
|
||||
test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => {
|
||||
const prompt = buildWechatAgentPrompt({
|
||||
msgType: 'text',
|
||||
|
||||
Reference in New Issue
Block a user