Compare commits

...

16 Commits

Author SHA1 Message Date
tkmind 5704cb11b0 Merge pull request 'fix(page-data): deliver MindSpace workspace URLs for questionnaires' (#9) from release/0712-portal-prep into main
Merge pull request #9: fix(page-data) deliver MindSpace workspace URLs for questionnaires
2026-07-12 06:03:42 +00:00
john b86ce98d6f merge: sync origin/main before portal release 2026-07-12 13:54:27 +08:00
john 294e331a03 fix(page-data): deliver MindSpace workspace URLs instead of /u/.../pages/ routes.
Page Data questionnaires were returning publication slugs to users; password-mode
pages always fell back to /u/ routes. Prefer MindSpace paths at publish time and
rewrite mistaken delivery links in WeChat and Portal chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:54:24 +08:00
john ee3e405fc7 Merge branch 'codex/portal-resvg-linux-arm64' 2026-07-12 12:19:11 +08:00
john 048f25f580 feat(miniapp): add privacy consent and legal pages
Show privacy popup on launch, gate login on legal consent, and link
user agreement and privacy policy pages in the mini program.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:18:54 +08:00
john 8928827291 Merge branch 'codex/0708-dev' 2026-07-12 12:18:51 +08:00
john 4e36b77237 Merge branch 'codex/asset-gateway-foundation' 2026-07-12 12:18:51 +08:00
john dc7b440bfb Merge branch 'hotfix/mindspace-url-canonical-0710' 2026-07-12 12:18:51 +08:00
john fdc2712084 fix: bundle Linux ARM64 resvg runtime dependency 2026-07-12 12:04:02 +08:00
john 680d28ac4b fix: reject missing local mindspace cover files 2026-07-12 10:15:26 +08:00
john f4d9897072 feat: add optional asset gateway control plane 2026-07-12 09:51:13 +08:00
john 7dd6b060f7 merge: stabilize MindSpace local workflows 2026-07-12 08:14:13 +08:00
john a3f669d4e7 fix: stabilize MindSpace local workflows
Signed-off-by: john <cynell@139.com>
2026-07-12 08:12:15 +08:00
john 880cd59ca5 fix(mindspace): link inline public html paths 2026-07-10 11:53:03 +08:00
john adc13a167e fix(mindspace): canonicalize public page links without MindSpace segment 2026-07-10 11:39:21 +08:00
john 31742f914e Add 103 H5 rich page rollout runbook 2026-07-08 10:33:23 +08:00
61 changed files with 3004 additions and 56 deletions
+7 -1
View File
@@ -13,9 +13,10 @@
// domain logic has a single source of truth; only the wiring differs.
import path from 'node:path';
import { createSubscriptionService } from './billing-subscription.mjs';
import { createDbPool, isDatabaseConfigured } from './db.mjs';
import { createDbPool, ensureAssetGatewaySchema, isDatabaseConfigured } from './db.mjs';
import { createUserAuth } from './user-auth.mjs';
import { createLlmProviderService } from './llm-providers.mjs';
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
@@ -55,6 +56,9 @@ export async function createAdminServices(env = {}) {
);
const pool = createDbPool();
// The back-office process can boot before the public Portal. Create only the
// optional control-plane tables here instead of requiring the public boot path.
await ensureAssetGatewaySchema(pool);
// --- plaza graph (review queue, reports, featured, analytics, creators) ---
const plazaRedis = createNoopPlazaRedis();
@@ -94,6 +98,7 @@ export async function createAdminServices(env = {}) {
}
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
@@ -131,6 +136,7 @@ export async function createAdminServices(env = {}) {
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
memoryV2ConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
+28
View File
@@ -42,6 +42,7 @@ export function createAdminApi({
ready,
userAuth,
llmProviderService,
assetGatewayConfigService,
memoryV2ConfigService,
adminSystemTestService,
plazaPosts,
@@ -91,6 +92,33 @@ export function createAdminApi({
res.json({ summary: { ...summary, llm } });
});
adminApi.get('/asset-gateway/config', requireAdmin, async (_req, res) => {
if (!assetGatewayConfigService?.getConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
return res.json(await assetGatewayConfigService.getConfig());
});
adminApi.put('/asset-gateway/config', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updateGlobalConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
return res.json(await assetGatewayConfigService.updateGlobalConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
}));
});
adminApi.put('/asset-gateway/plugins/:pluginId', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updatePluginConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
const result = await assetGatewayConfigService.updatePluginConfig(req.params.pluginId, req.body ?? {}, {
updatedBy: req.currentUser.id,
});
if (!result.ok) return res.status(400).json({ message: result.message });
return res.json(result);
});
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
+42
View File
@@ -90,6 +90,48 @@ test('admin memory-v2 config routes expose config and runtime state', async () =
}
});
test('admin asset gateway routes preserve an explicit, admin-only control plane', async () => {
const calls = [];
const router = createAdminApi({
jsonBody: express.json(),
getToken() { return 'token-admin'; },
userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } },
llmProviderService: null,
memoryV2ConfigService: null,
assetGatewayConfigService: {
async getConfig() { return { enabled: false, plugins: [] }; },
async updateGlobalConfig(payload, context) { calls.push({ type: 'global', payload, context }); return { enabled: true }; },
async updatePluginConfig(pluginId, payload, context) {
calls.push({ type: 'plugin', pluginId, payload, context });
return { ok: true, config: { enabled: true, plugins: [] } };
},
},
plazaPosts: null,
plazaOps: null,
wechatAdmin: null,
subscriptionService: null,
});
const server = await startTestServer(router);
try {
const read = await fetch(`${server.baseUrl}/admin-api/asset-gateway/config`, {
headers: { cookie: 'h5_user_session=token-admin' },
});
assert.equal(read.status, 200);
assert.deepEqual(await read.json(), { enabled: false, plugins: [] });
const update = await fetch(`${server.baseUrl}/admin-api/asset-gateway/plugins/asset-generate`, {
method: 'PUT',
headers: { 'content-type': 'application/json', cookie: 'h5_user_session=token-admin' },
body: JSON.stringify({ enabled: true, provider: 'flux-schnell' }),
});
assert.equal(update.status, 200);
assert.equal(calls[0].pluginId, 'asset-generate');
assert.equal(calls[0].context.updatedBy, 'admin-1');
} finally {
await server.close();
}
});
test('admin system test route executes shared validation service', async () => {
const calls = [];
const router = createAdminApi({
+1
View File
@@ -76,6 +76,7 @@ const CONSOLES = {
getToken,
userAuth: services.userAuth,
llmProviderService: services.llmProviderService,
assetGatewayConfigService: services.assetGatewayConfigService,
memoryV2ConfigService: services.memoryV2ConfigService,
adminSystemTestService: services.adminSystemTestService,
plazaPosts: services.plazaPosts,
+213
View File
@@ -0,0 +1,213 @@
import crypto from 'node:crypto';
export const ASSET_PLUGIN_CATALOG = [
{
id: 'asset-search',
label: '素材检索',
description: '从已授权图库检索素材;不改变现有页面默认生成流程。',
providers: ['pexels', 'unsplash'],
supportsLlm: true,
},
{
id: 'asset-generate',
label: '图片生成',
description: '异步生成页面所需图片;关闭或失败时必须降级为无图页面。',
providers: ['flux-schnell', 'comfyui'],
supportsLlm: true,
},
{
id: 'asset-transform',
label: '图片处理',
description: '压缩、裁剪和格式转换等确定性处理。',
providers: ['sharp'],
supportsLlm: false,
},
{
id: 'asset-analyze',
label: '图片理解',
description: '对图片进行描述、结构分析或辅助可访问性标注。',
providers: ['qwen25-vl'],
supportsLlm: true,
},
];
const pluginById = new Map(ASSET_PLUGIN_CATALOG.map((plugin) => [plugin.id, plugin]));
function normalizePluginId(value) {
const id = String(value ?? '').trim().toLowerCase();
return pluginById.has(id) ? id : null;
}
function normalizeProvider(plugin, value) {
const provider = String(value ?? '').trim().toLowerCase();
return plugin?.providers.includes(provider) ? provider : null;
}
function toBoolean(value, defaultValue = false) {
if (value === undefined || value === null) return defaultValue;
return Boolean(value);
}
function rowToPluginConfig(row, providerKey = null) {
const plugin = pluginById.get(row.plugin_id);
return {
pluginId: row.plugin_id,
label: plugin?.label ?? row.plugin_id,
description: plugin?.description ?? '',
providers: plugin?.providers ?? [],
supportsLlm: Boolean(plugin?.supportsLlm),
enabled: Boolean(row.enabled),
provider: row.provider,
llmProviderKeyId: row.llm_provider_key_id ?? null,
llmProviderName: providerKey?.name ?? null,
llmProviderId: providerKey?.providerId ?? null,
llmModel: row.llm_model ?? null,
updatedAt: Number(row.updated_at ?? 0) || null,
};
}
/**
* Configuration-only control plane for optional asset plugins.
*
* This service deliberately does not invoke a model, external API, queue, or
* storage. Callers must opt in to it, which keeps the current chat and page
* generation path unchanged until a later adapter explicitly uses a plugin.
*/
export function createAssetGatewayConfigService(pool, { llmProviderService = null } = {}) {
async function getGlobalRow() {
const [rows] = await pool.query(
'SELECT * FROM h5_asset_gateway_config WHERE config_key = ? LIMIT 1',
['default'],
);
return rows[0] ?? null;
}
async function listRows() {
const [rows] = await pool.query('SELECT * FROM h5_asset_plugin_configs ORDER BY plugin_id');
return rows;
}
async function getRow(pluginId) {
const [rows] = await pool.query(
'SELECT * FROM h5_asset_plugin_configs WHERE plugin_id = ? LIMIT 1',
[pluginId],
);
return rows[0] ?? null;
}
async function listKeyMap() {
if (!llmProviderService?.listKeys) return new Map();
const keys = await llmProviderService.listKeys();
return new Map(keys.map((key) => [key.id, key]));
}
async function ensureLlmBinding({ plugin, keyId, model, enabled }) {
if (!plugin.supportsLlm && (keyId || model)) {
return { ok: false, message: `${plugin.label}不支持 LLM 绑定` };
}
if (!enabled || (!keyId && !model)) return { ok: true };
if (!keyId || !model) return { ok: false, message: 'LLM Provider 与模型必须同时选择' };
if (!llmProviderService?.listKeys) return { ok: false, message: 'LLM Provider 服务未启用' };
const key = (await llmProviderService.listKeys()).find((item) => item.id === keyId);
if (!key || key.status !== 'active') return { ok: false, message: 'LLM Provider 不存在或已禁用' };
if (!key.models.includes(model)) return { ok: false, message: '模型不在该 Provider 支持列表中' };
return { ok: true };
}
return {
catalog: ASSET_PLUGIN_CATALOG,
async getConfig() {
const [globalRow, rows, keyMap] = await Promise.all([getGlobalRow(), listRows(), listKeyMap()]);
const byPlugin = new Map(rows.map((row) => [row.plugin_id, row]));
return {
enabled: Boolean(globalRow?.enabled),
updatedAt: Number(globalRow?.updated_at ?? 0) || null,
plugins: ASSET_PLUGIN_CATALOG.map((plugin) => {
const row = byPlugin.get(plugin.id);
return row
? rowToPluginConfig(row, keyMap.get(row.llm_provider_key_id) ?? null)
: {
pluginId: plugin.id,
label: plugin.label,
description: plugin.description,
providers: plugin.providers,
supportsLlm: plugin.supportsLlm,
enabled: false,
provider: null,
llmProviderKeyId: null,
llmProviderName: null,
llmProviderId: null,
llmModel: null,
updatedAt: null,
};
}),
};
},
async updateGlobalConfig(payload = {}, { updatedBy = null } = {}) {
const enabled = toBoolean(payload.enabled);
const now = Date.now();
const existing = await getGlobalRow();
if (existing) {
await pool.query(
'UPDATE h5_asset_gateway_config SET enabled = ?, updated_by = ?, updated_at = ? WHERE config_key = ?',
[enabled ? 1 : 0, updatedBy, now, 'default'],
);
} else {
await pool.query(
'INSERT INTO h5_asset_gateway_config (config_key, enabled, updated_by, updated_at) VALUES (?, ?, ?, ?)',
['default', enabled ? 1 : 0, updatedBy, now],
);
}
return this.getConfig();
},
async updatePluginConfig(rawPluginId, payload = {}, { updatedBy = null } = {}) {
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId) return { ok: false, message: '不支持的资产插件' };
const plugin = pluginById.get(pluginId);
const enabled = toBoolean(payload.enabled);
const provider = normalizeProvider(plugin, payload.provider);
if (enabled && !provider) return { ok: false, message: '请选择该插件支持的 Provider' };
const keyId = String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null;
const model = String(payload.llmModel ?? payload.model ?? '').trim() || null;
const binding = await ensureLlmBinding({ plugin, keyId, model, enabled });
if (!binding.ok) return binding;
const existing = await getRow(pluginId);
const now = Date.now();
if (existing) {
await pool.query(
`UPDATE h5_asset_plugin_configs
SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, updated_by = ?, updated_at = ?
WHERE plugin_id = ?`,
[enabled ? 1 : 0, provider, keyId, model, updatedBy, now, pluginId],
);
} else {
await pool.query(
`INSERT INTO h5_asset_plugin_configs
(id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, updatedBy, now, now],
);
}
return { ok: true, config: await this.getConfig() };
},
async resolvePlugin(rawPluginId) {
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId) return { ok: false, code: 'unsupported_plugin', message: '不支持的资产插件' };
const global = await getGlobalRow();
if (!global?.enabled) {
return { ok: false, code: 'gateway_disabled', message: '资产能力未启用,可安全降级到原有流程' };
}
const row = await getRow(pluginId);
if (!row?.enabled) {
return { ok: false, code: 'plugin_disabled', message: '资产插件未启用,可安全降级到原有流程' };
}
return { ok: true, plugin: rowToPluginConfig(row) };
},
};
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
function createPool() {
let global = null;
const plugins = new Map();
return {
async query(sql, params = []) {
const compact = sql.replace(/\s+/g, ' ').trim();
if (compact.startsWith('SELECT * FROM h5_asset_gateway_config')) return [[global].filter(Boolean)];
if (compact === 'SELECT * FROM h5_asset_plugin_configs ORDER BY plugin_id') {
return [[...plugins.values()].sort((a, b) => a.plugin_id.localeCompare(b.plugin_id))];
}
if (compact.startsWith('SELECT * FROM h5_asset_plugin_configs WHERE plugin_id')) {
return [[plugins.get(params[0])].filter(Boolean)];
}
if (compact.startsWith('INSERT INTO h5_asset_gateway_config')) {
global = { config_key: params[0], enabled: params[1], updated_by: params[2], updated_at: params[3] };
return [{}];
}
if (compact.startsWith('UPDATE h5_asset_gateway_config')) {
global = { ...global, enabled: params[0], updated_by: params[1], updated_at: params[2] };
return [{}];
}
if (compact.startsWith('INSERT INTO h5_asset_plugin_configs')) {
plugins.set(params[1], {
id: params[0], plugin_id: params[1], enabled: params[2], provider: params[3],
llm_provider_key_id: params[4], llm_model: params[5], updated_by: params[6],
created_at: params[7], updated_at: params[8],
});
return [{}];
}
if (compact.startsWith('UPDATE h5_asset_plugin_configs')) {
const pluginId = params[6];
plugins.set(pluginId, {
...plugins.get(pluginId), enabled: params[0], provider: params[1],
llm_provider_key_id: params[2], llm_model: params[3], updated_by: params[4], updated_at: params[5],
});
return [{}];
}
throw new Error(`Unexpected SQL: ${compact}`);
},
};
}
const llmProviderService = {
async listKeys() {
return [{
id: 'provider-key-1', name: 'Primary', providerId: 'custom', status: 'active', models: ['fast-model'],
}];
},
};
test('asset gateway stays disabled by default and never blocks callers that choose to fall back', async () => {
const service = createAssetGatewayConfigService(createPool(), { llmProviderService });
const config = await service.getConfig();
assert.equal(config.enabled, false);
assert.equal(config.plugins.length, 4);
assert.deepEqual(await service.resolvePlugin('asset-generate'), {
ok: false,
code: 'gateway_disabled',
message: '资产能力未启用,可安全降级到原有流程',
});
});
test('each optional asset plugin can bind its own approved LLM model', async () => {
const service = createAssetGatewayConfigService(createPool(), { llmProviderService });
await service.updateGlobalConfig({ enabled: true }, { updatedBy: 'admin-1' });
const result = await service.updatePluginConfig('asset-generate', {
enabled: true,
provider: 'flux-schnell',
llmProviderKeyId: 'provider-key-1',
llmModel: 'fast-model',
}, { updatedBy: 'admin-1' });
assert.equal(result.ok, true);
const plugin = result.config.plugins.find((item) => item.pluginId === 'asset-generate');
assert.equal(plugin.enabled, true);
assert.equal(plugin.provider, 'flux-schnell');
assert.equal(plugin.llmModel, 'fast-model');
assert.equal((await service.resolvePlugin('asset-generate')).ok, true);
});
test('asset plugins reject unknown providers and unsupported LLM bindings', async () => {
const service = createAssetGatewayConfigService(createPool(), { llmProviderService });
const invalidProvider = await service.updatePluginConfig('asset-generate', {
enabled: true,
provider: 'unknown-provider',
});
assert.equal(invalidProvider.ok, false);
assert.match(invalidProvider.message, /Provider/);
const unsupportedLlm = await service.updatePluginConfig('asset-transform', {
enabled: true,
provider: 'sharp',
llmProviderKeyId: 'provider-key-1',
llmModel: 'fast-model',
});
assert.equal(unsupportedLlm.ok, false);
assert.match(unsupportedLlm.message, /不支持 LLM/);
});
+1 -1
View File
@@ -156,7 +156,7 @@ export function buildChatSkillPrompt(promptKey, skillName) {
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' +
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
'禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后返回 workspaceUrl,并说明后台入口与口令。'
'禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后返回 workspaceUrl/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。'
);
case 'service-integration-smoke':
return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
+33
View File
@@ -96,6 +96,35 @@ async function ensureForeignKeyDeleteRule(
);
}
/** Creates only the optional asset control-plane tables needed by memind_adm. */
export async function ensureAssetGatewaySchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_asset_gateway_config (
config_key VARCHAR(32) PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs (
id CHAR(36) PRIMARY KEY,
plugin_id VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
provider VARCHAR(64) NULL,
llm_provider_key_id CHAR(36) NULL,
llm_model VARCHAR(128) NULL,
updated_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uq_h5_asset_plugin (plugin_id),
KEY idx_h5_asset_plugin_llm_provider (llm_provider_key_id),
CONSTRAINT fk_h5_asset_plugin_llm_provider FOREIGN KEY (llm_provider_key_id)
REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
export async function migrateSchema(pool) {
const renames = [
['h5_user_sessions', 'goose_session_id', 'agent_session_id'],
@@ -217,6 +246,10 @@ export async function migrateSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Optional asset capability control plane. These rows configure no runtime
// worker by themselves; the existing chat/page path remains independent.
await ensureAssetGatewaySchema(pool);
// Shared experience store (etat C). Keep in sync with schema.sql.
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_experience (
@@ -0,0 +1,243 @@
# 103 H5 页面生成灰度与发布复盘(2026-07-07)
> 适用场景:H5 / 服务号聊天中,长内容、结构化内容、适合读者阅读或转发的内容没有自动生成页面,或 103 开关/发布后需要做受控验证。
## 结论摘要
本次问题不是 `static-page-publish``sandbox-fs` 完全不可用,而是三层问题叠加:
1. 103 起初未开启 H5 session 灰度开关,生产仍停在旧路径。
2. Router 将“长新闻 + 请整理给服务号用户阅读”判成 `direct_chat`,导致只回复摘要,不进入页面生成链路。
3. Portal runtime 发布后,旧 `goosed-prod-*` 容器仍握着旧 live 目录 inode 的 bind mount,导致容器内写入成功但 host 当前 `/Users/john/Project/Memind/MindSpace` 看不到。
最终上线版本:
- Portal release: `20260707-154245-ea4c6b7`
- Git commit: `ea4c6b707c2a95c5c63eed4ed5dc02f8a6f786bc`
- Branch used for release: `release/rich-page-router-0707`
- Production host: `john@58.38.22.103`
## 安全边界
- 不从脏工作区发包。
- 不从功能分支、局部文件或单 commit 手工拷贝到 103。
- 不直接 `rsync` 到 103/105。
- Portal 发包必须走 `bash scripts/release-portal-runtime-prod.sh --yes`
- `.env` 灰度开关可以作为运行配置变更,但必须先备份、记录、重启、健康检查。
- goosed 容器重建是运行态 remount 修复,不是源码变更;执行前后必须检查 18006-18014 健康。
## 本次启用的 103 H5 灰度开关
103 `.env` 当前启用:
```bash
MEMIND_SESSION_BROKER_ENABLED=1
MEMIND_ROUTER_NORMALIZED_DECISION=1
MEMIND_SSE_EVENT_TAXONOMY=1
MEMIND_RUN_STREAM_REPLAY=1
MEMIND_H5_HTML_FINISH_GUARD=1
MEMIND_SESSION_STREAM_REPLAY=1
MEMIND_SESSION_BROKER_METRICS=1
```
启用前备份:
```bash
/Users/john/Project/Memind/.env.backup-h5-session-flags-20260707-151846
```
启动日志应看到:
```text
[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)
[Portal] H5 session flags: MEMIND_SESSION_BROKER_ENABLED, ...
```
## 标准验证流程
### 1. 基础健康
103 本机:
```bash
ssh john@58.38.22.103 'curl -fsS http://127.0.0.1:8081/api/status'
```
外部入口:
```bash
curl -ksS https://m.tkmind.cn/api/status
```
Manifest
```bash
ssh john@58.38.22.103 'cat /Users/john/Project/Memind/.release-manifest.txt'
```
### 2. 账号级 smoke
使用生产账号登录后,创建 session 并发一条短消息。期望:
- `/api/agent/runs` 返回 `202`
- run 最终 `succeeded`
- 会话里 `message_count=2`
- 助手回复 `OK`
### 3. 页面链路 smoke
发明确页面生成请求。期望:
- run events 有 `intent_routed`
- route 为 `agent_orchestration`
- `suggestedSkill=static-page-publish`
- goosed 原始 session 有 `load_skill``sandbox-fs__write_file`
- 最终 Markdown 链接能 `HTTP 200`
示例验收 URL
```text
https://m.tkmind.cn/MindSpace/a6fb1e97-2b0f-447b-b138-4561d8e5c53e/public/world-news-20260707.html
```
### 4. 长内容/服务号场景 smoke
复测用户原始形态:
```text
以下是今天(2026年7月7日,星期二)国际上的主要热点新闻:
...
请整理给服务号用户阅读。
```
期望 run events
```json
{
"eventType": "intent_routed",
"data": {
"route": "agent_orchestration",
"reason": "结构化长内容适合生成页面承载",
"source": "rule",
"suggestedSkill": "static-page-publish"
}
}
```
## 本次代码调整
文件:
- `chat-intent-router.mjs`
- `chat-intent-router.test.mjs`
策略:
- 保留短问答、普通摘要、故事/诗歌等 direct chat。
- 对“服务号/公众号/H5/读者/用户/转发/分享/阅读”等场景中较长、结构化、多条目内容,优先走 `static-page-publish`
- 避免完全依赖 LLM router 判断,否则容易把“整理给服务号用户阅读”判为纯文字摘要。
本地验证:
```bash
node --test chat-intent-router.test.mjs direct-chat-service.test.mjs chat-skills.test.mjs
npm run verify:h5-session-patches
```
发包脚本也会跑:
```bash
npm test -- --test-name-pattern='publish|space|billing'
npm run verify:mindspace-publish-guards
npm run verify:mindspace-publish-guards:full
```
## 发版路径
本次没有从原始脏工作区发包,而是使用干净 worktree:
```bash
git worktree add -b codex/rich-page-router /Users/john/Project/Memind-rich-page-router main
git worktree add -b release/rich-page-router-0707 /private/tmp/memind-release-rich-page main
```
发布前闸门:
```bash
bash scripts/check-release-ready.sh --skip-fetch
```
正式发布:
```bash
bash scripts/release-portal-runtime-prod.sh --yes
```
发布完成后推送 `main`,保证 manifest commit 可追溯:
```bash
git push origin main
```
## 关键踩坑:goosed bind mount 旧 inode
现象:
- goosed 原始会话显示 `sandbox-fs__write_file public/today-news-20260707.html` 成功。
- 容器内能看到文件。
- host 当前 `/Users/john/Project/Memind/MindSpace/.../public` 看不到文件。
- 公网 URL 404 或被 `MEMIND_H5_HTML_FINISH_GUARD` 拦截。
原因:
Portal runtime 发布会移动旧 live 目录到 archive,再展开新 runtime live 目录。已经运行的 `goosed-prod-*` 容器仍握着旧目录 inode 的 bind mount。
确认命令:
```bash
ssh john@58.38.22.103 '
/opt/homebrew/bin/docker exec goosed-prod-1 sh -lc "ls -l /Users/john/Project/Memind/MindSpace/<user-id>/public/<file>.html"
ls -l /Users/john/Project/Memind/MindSpace/<user-id>/public/<file>.html
'
```
修复:
```bash
ssh john@58.38.22.103 '
cd /Users/john/Project/goosed-prod
/opt/homebrew/bin/docker compose -f docker-compose.prod.yml up -d --force-recreate
'
```
健康检查:
```bash
ssh john@58.38.22.103 '
/opt/homebrew/bin/docker ps --format "{{.Names}} {{.Status}}" | grep goosed-prod | sort -V
for p in $(seq 18006 18014); do curl -kfsS "https://127.0.0.1:$p/status" >/dev/null || exit 1; done
curl -fsS http://127.0.0.1:8081/api/status
'
```
经验:Portal runtime 发布后,如果本次涉及 MindSpace 页面写入,应该把 goosed remount 检查纳入发布后验收;必要时重建 goosed 容器。
## 下次 Checklist
1. 确认目标:是本地、103、还是 105 入口问题。
2. 先只读确认生产 manifest、flags、health。
3. 用真实生产账号做短消息 smoke。
4. 用明确页面生成请求做 `write_file + URL 200` smoke。
5. 用用户原始表达做路由 smoke,检查 `intent_routed`
6.`direct_chat`:修 router 规则或 prompt,不要先开通用 code/tool gateway。
7. 若有 `write_file` 但 URL 404:检查 host/container 同路径可见性,重点怀疑 goosed bind mount 旧 inode。
8. 只从干净 release worktree 发包。
9. 发包后验证 manifest、外部入口、真实业务页面链接。
10. 推送 `main`,确保生产 manifest commit 可追溯。
## 不建议的做法
- 不要因为页面没生成就直接打开 `MEMIND_AGENT_CODE_RUNS_ENABLED``MEMIND_TOOL_GATEWAY_ENABLED`
- 页面发布主要应走 goosed + `sandbox-fs`,不是通用 code executor。
- 不要只看 run `succeeded`,必须看是否有 `write_file` 和公网 URL 200。
- 不要把 guard 拦截当作失败本身;guard 是保护用户不收到假链接,真正问题在路由或落盘。
+16
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const JOB_TYPES = new Set(['generate_page', 'analyze_asset', 'summarize']);
@@ -625,6 +626,20 @@ export function createAgentJobService(pool, options = {}) {
return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now });
};
const setSessionId = async (jobId, token, sessionId) => {
await requireJobToken(jobId, token);
const normalizedSessionId = String(sessionId ?? '').trim();
if (!normalizedSessionId) {
throw agentJobError('Agent 会话不能为空', 'invalid_agent_job_input');
}
await pool.query(
`UPDATE h5_agent_jobs
SET session_id = ?, updated_at = ?
WHERE id = ?`,
[normalizedSessionId, nowFactory(), jobId],
);
};
const completeJob = async (jobId, token, input) => {
const job = await requireJobToken(jobId, token);
const now = nowFactory();
@@ -722,6 +737,7 @@ export function createAgentJobService(pool, options = {}) {
reapStaleJobs,
getAssetForJob,
heartbeat,
setSessionId,
completeJob,
};
}
+1
View File
@@ -362,6 +362,7 @@ export function createMindSpaceAgentRunner({
if (!sessionId) {
throw runnerError('Agent 会话启动失败', 'worker_unavailable');
}
await agentJobService.setSessionId(jobId, claim.jobToken, sessionId);
await sessionStore.registerAgentSession(claim.userId, sessionId);
await reconcileAgentSession(
(pathname, init) => apiFetch(pathname, init),
+4
View File
@@ -121,6 +121,9 @@ test('runner claims job, executes reply, bills usage, and completes job', async
path: file,
};
},
async setSessionId(jobId, token, sessionId) {
calls.push(['setSessionId', jobId, token, sessionId]);
},
async completeJob(jobId, token, payload) {
calls.push(['completeJob', jobId, token, payload]);
return { id: jobId, status: payload.status ?? 'completed', resultPageId: 'page-1' };
@@ -150,6 +153,7 @@ test('runner claims job, executes reply, bills usage, and completes job', async
const result = await runner.runJob('job-1');
assert.equal(result.status, 'completed');
assert.equal(calls.some((item) => item[0] === 'setSessionId' && item[3] === 'session-1'), true);
assert.equal(calls.some((item) => item[0] === 'billSessionUsage'), true);
const completeCall = calls.find((item) => item[0] === 'completeJob');
assert.equal(completeCall[3].title, '项目周报');
@@ -0,0 +1,308 @@
/**
* Page Data 问卷交付闸门集成测试:微信通道 + Finish guard 与本地/生产对齐。
*/
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createWechatMpService } from './wechat-mp.mjs';
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import {
evaluatePageDataFinishGuard,
maybeAutoBindPageDataHtmlPages,
maybeRepairPageDataAfterFinish,
} from './mindspace-page-data-finish-guard.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
const SURVEY_USER_TEXT = '帮我设计一个调查问卷,关于儿童饮食偏好方面,做三个问题吧,要加一个后台';
const VALID_SURVEY_HTML = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', {
child_age: '6-8',
veggie_habit: '一般',
snack_type: '水果',
});
</script></body></html>`;
const BAD_SURVEY_HTML = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script>
async function save(data) {
try {
if (typeof MindSpacePageData !== 'undefined') {
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
return;
}
} catch (e) {}
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
}
</script></body></html>`;
function signatureFor(token, timestamp, nonce) {
return crypto.createHash('sha1').update([token, timestamp, nonce].sort().join('')).digest('hex');
}
function inboundXml({ content = SURVEY_USER_TEXT } = {}) {
return [
'<xml>',
'<ToUserName><![CDATA[gh_test]]></ToUserName>',
'<FromUserName><![CDATA[openid-page-data]]></FromUserName>',
'<CreateTime>1710000000</CreateTime>',
'<MsgType><![CDATA[text]]></MsgType>',
`<Content><![CDATA[${content}]]></Content>`,
'<MsgId>10001</MsgId>',
'</xml>',
].join('');
}
function jsonEscapeHtml(html) {
return String(html).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function createWechatUserAuth(workspaceRoot) {
return {
async findWechatUserByOpenid() {
return { userId: 'user-page-data', status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
return { agentSessionId: 'session-page-data-1' };
},
async clearWechatAgentRoute() {},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return workspaceRoot;
},
async getAgentSessionPolicy() {
return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
},
async getUserPublishLayout() {
return {
publishDir: workspaceRoot,
displayName: '唐',
username: 'wx_test',
slug: 'wx_test',
constraints: null,
};
},
async billSessionUsage() {},
async recordWechatMpMessage() {
return { inserted: true };
},
async finishWechatMpMessage() {},
async insertWechatMpMessageDetail() {},
async upsertWechatAgentRoute() {},
async registerAgentSession() {},
};
}
function createWechatFetchRecorder(wechatCalls) {
return async (url, init = {}) => {
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
};
}
async function setupSurveyWorkspace(workspaceRoot) {
fs.mkdirSync(path.join(workspaceRoot, 'public'), { recursive: true });
const dataSpace = createUserDataSpaceService({ workspaceRoot });
await dataSpace.executeSql(`CREATE TABLE diet_survey (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_age TEXT NOT NULL DEFAULT '',
veggie_habit TEXT NOT NULL DEFAULT '',
snack_type TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT ''
);`);
await dataSpace.upsertDataset({
name: 'diet_survey',
table: 'diet_survey',
description: '儿童饮食偏好调查问卷数据',
actions: ['read', 'insert'],
columns: {
read: ['id', 'child_age', 'veggie_habit', 'snack_type', 'created_at'],
insert: ['child_age', 'veggie_habit', 'snack_type'],
},
});
}
test('buildWechatAgentPrompt injects page-data-collect requirements for survey requests', () => {
const prompt = buildWechatAgentPrompt(
{ msgType: 'text', agentText: SURVEY_USER_TEXT },
{ grantedSkills: ['page-data-collect', 'static-page-publish'] },
);
assert.match(prompt, /page-data-collect/);
assert.match(prompt, /禁止 localStorage/);
assert.match(prompt, /private_data_bind_workspace_page/);
});
test('integration: wechat mp blocks localStorage survey delivery with page-data failure notice', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const wechatCalls = [];
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-page-data-block-'));
const htmlPath = path.join(workspaceRoot, 'public', 'children-diet-survey.html');
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(htmlPath, BAD_SURVEY_HTML, 'utf8');
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://m.tkmind.cn',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
progressDelayMs: 0,
},
userAuth: createWechatUserAuth(workspaceRoot),
pageDataFinishGuard: null,
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}/events`) {
return new Response(
[
`data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"sandbox-fs__write_file","arguments":{"path":"public/children-diet-survey.html","content":"${jsonEscapeHtml(BAD_SURVEY_HTML)}"}}}}]}}\n\n`,
'data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"问卷已发布:https://m.tkmind.cn/MindSpace/user-page-data/public/children-diet-survey.html"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-page-data-bad","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === `/sessions/${sessionId}/reply`) {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
},
wechatFetch: createWechatFetchRecorder(wechatCalls),
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-page-data-bad';
try {
const result = await service.handleInboundMessage(inboundXml(), {
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
});
assert.equal(result.status, 200);
await result.task;
assert.ok(fs.existsSync(htmlPath), 'bad survey html should remain on disk for inspection');
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
assert.ok(sendCall, 'wechat should send a customer service message');
const payload = JSON.parse(sendCall[2]);
assert.match(payload.text.content, /page-data-collect|Page Data API|localStorage/i);
assert.doesNotMatch(
payload.text.content,
/https:\/\/m\.tkmind\.cn\/MindSpace\/user-page-data\/public\/children-diet-survey\.html/,
);
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test('integration: finish guard auto-bind clears unbound state for valid survey html', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-autobind-'));
try {
await setupSurveyWorkspace(workspaceRoot);
fs.writeFileSync(path.join(workspaceRoot, 'public', 'diet-survey.html'), VALID_SURVEY_HTML, 'utf8');
const before = evaluatePageDataFinishGuard({
publishDir: workspaceRoot,
agentText: SURVEY_USER_TEXT,
messages: [],
});
assert.equal(before.unboundFiles.length, 1);
const autoBind = await maybeAutoBindPageDataHtmlPages({
pool: null,
userId: 'user-page-data',
publishDir: workspaceRoot,
h5Root: workspaceRoot,
storageRoot: workspaceRoot,
});
assert.equal(autoBind.bound.length, 0);
assert.equal(autoBind.errors[0]?.code, 'database_unconfigured');
writePageAccessPolicy(workspaceRoot, {
pageId: 'page-diet-survey',
ownerUserId: 'user-page-data',
accessMode: 'public',
datasets: {
diet_survey: {
insert: true,
read: false,
columns: { insert: ['child_age', 'veggie_habit', 'snack_type'] },
},
},
});
const after = evaluatePageDataFinishGuard({
publishDir: workspaceRoot,
agentText: SURVEY_USER_TEXT,
messages: [],
});
assert.equal(after.unboundFiles.length, 0);
assert.equal(after.htmlIssues.length, 0);
assert.equal(after.needsRepair, false);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test('integration: H5 finish guard triggers repair prompt for invalid survey html', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-h5-repair-'));
const submitCalls = [];
try {
await setupSurveyWorkspace(workspaceRoot);
fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
const result = await maybeRepairPageDataAfterFinish({
sessionId: 'session-h5-page-data',
userId: 'user-page-data',
publishDir: workspaceRoot,
messages: [],
pool: null,
h5Root: workspaceRoot,
storageRoot: workspaceRoot,
userText: SURVEY_USER_TEXT,
tkmindProxy: {
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
submitCalls.push({ userId, sessionId, requestId, userMessage });
},
},
});
assert.equal(result.repaired, true);
assert.equal(result.triggered, true);
assert.equal(submitCalls.length, 1);
assert.match(submitCalls[0].userMessage.content[0].text, /localStorage/);
assert.match(submitCalls[0].userMessage.content[0].text, /private_data_bind_workspace_page/);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
+531
View File
@@ -0,0 +1,531 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { isPageDataIntent } from './chat-skills.mjs';
import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs';
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
import { listPageAccessPolicies } from './page-data-policy-store.mjs';
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
const PUBLICATION_ROUTE_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/u\/([0-9a-f-]{36}|[a-z0-9._-]+)\/pages\/([^\s<>"')\]]+)/gi;
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
const LOCAL_STORAGE_DATA_PATTERN = /localStorage\.(?:getItem|setItem)\s*\(/i;
const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|localStorage\s*fallback/i;
const repairAttemptsBySession = new Map();
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function readPublicHtmlFiles(publishDir) {
const publicDir = path.join(path.resolve(String(publishDir ?? '')), '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 collectPageDataDeliveryArtifacts(
publishDir,
{ publicBaseUrl = resolvePublicBaseUrl() } = {},
) {
const ownerKey = path.basename(path.resolve(String(publishDir ?? '')));
if (!ownerKey) return [];
return collectPageDataPublicHtmlFiles(publishDir)
.map((file) => ({
localPath: file.absolutePath,
relativePath: file.relativePath,
isAdmin: /-admin\.html$/i.test(file.relativePath),
url: buildPublicUrl(publicBaseUrl, ownerKey, file.relativePath),
}));
}
function pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex }) {
const normalizedSlug = String(slug ?? '').toLowerCase();
const adminArtifact = artifacts.find((artifact) => artifact.isAdmin);
const surveyArtifacts = artifacts.filter((artifact) => !artifact.isAdmin);
if (adminArtifact && /admin/.test(normalizedSlug) && !adminUsed.value) {
adminUsed.value = true;
return adminArtifact.url;
}
if (surveyArtifacts[surveyIndex.value]) {
surveyIndex.value += 1;
return surveyArtifacts[surveyIndex.value - 1].url;
}
if (adminArtifact && !adminUsed.value) {
adminUsed.value = true;
return adminArtifact.url;
}
return artifacts[0]?.url ?? null;
}
/** 将 Agent 误发的 /u/用户名/pages/... 链接改写为 MindSpace 工作区 URL。 */
export function rewritePageDataDeliveryLinks(text, artifacts = []) {
const value = String(text ?? '');
if (!value || !Array.isArray(artifacts) || artifacts.length === 0) return value;
const adminUsed = { value: false };
const surveyIndex = { value: 0 };
return value.replace(PUBLICATION_ROUTE_LINK_PATTERN, (match, _owner, slug) => {
const replacement = pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex });
return replacement ?? match;
});
}
export function buildPageDataDeliveryArtifactsFromBindResult(autoBind, publishDir, options = {}) {
const boundUrls = new Map(
(autoBind?.bound ?? [])
.filter((item) => item?.relativePath && item?.workspaceUrl)
.map((item) => [item.relativePath, item.workspaceUrl]),
);
return collectPageDataDeliveryArtifacts(publishDir, options).map((artifact) => ({
...artifact,
url: boundUrls.get(artifact.relativePath) ?? artifact.url,
}));
}
export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) {
const content = String(html ?? '');
const usage = detectPageDataDatasetUsageFromHtml(content);
const usesPageDataApi =
usage.size > 0 ||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
/\bMindSpacePageData\b/.test(content);
if (!usesPageDataApi) {
return { usesPageDataApi: false, issues: [] };
}
const issues = [];
if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) {
issues.push('missing_page_data_client_script');
}
if (LOCAL_STORAGE_DATA_PATTERN.test(content)) {
issues.push('forbidden_local_storage');
}
if (LOCAL_STORAGE_FALLBACK_HINT_PATTERN.test(content)) {
issues.push('forbidden_local_storage_fallback');
}
if (usage.size > 0 && !/\bMindSpacePageData\b/.test(content)) {
issues.push('missing_page_data_client_api');
}
if (relativePath && usage.size > 0) {
const hasInsert = [...usage.values()].some((item) => item.insert);
const hasRead = [...usage.values()].some((item) => item.read);
const looksAdmin = /-admin\.html$/i.test(relativePath);
if (looksAdmin && hasInsert && !hasRead) {
issues.push('admin_page_should_not_insert_only');
}
if (!looksAdmin && hasRead && !hasInsert) {
issues.push('survey_page_should_not_be_read_only');
}
}
return { usesPageDataApi: true, issues, usage };
}
export function collectPageDataPublicHtmlFiles(publishDir) {
return readPublicHtmlFiles(publishDir)
.map((file) => ({
...file,
evaluation: evaluatePageDataHtmlContent(file.content, { relativePath: file.relativePath }),
}))
.filter((file) => file.evaluation.usesPageDataApi);
}
export function inferPageDataBindAccessMode(relativePath, html) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const hasRead = [...usage.values()].some((item) => item.read);
const hasInsert = [...usage.values()].some((item) => item.insert);
if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
return 'password';
}
return 'public';
}
function pageHasBoundPolicy({ publishDir, relativePath, html }) {
const usage = detectPageDataDatasetUsageFromHtml(html);
if (!usage.size) return true;
const policies = listPageAccessPolicies(publishDir);
if (!policies.length) return false;
const htmlDatasetNames = [...usage.keys()].sort().join(',');
return policies.some((policy) => {
const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(',');
return policyNames === htmlDatasetNames;
});
}
export function usedPageDataCollectSkill(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => {
if (item?.type !== 'toolRequest') return false;
const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim();
const args = toolCall?.arguments ?? {};
const skillName = String(args.name ?? '').trim();
return name === 'load_skill' && skillName === 'page-data-collect';
}),
);
}
export function usedPageDataBindTool(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => {
if (item?.type !== 'toolRequest') return false;
const name = String(item.toolCall?.value?.name ?? '').trim();
return name === 'private_data_bind_workspace_page';
}),
);
}
export function extractRecentPageDataHtmlWrites(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();
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue;
const args = toolCall?.arguments ?? {};
const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
if (!candidate.toLowerCase().endsWith('.html')) continue;
const normalized = candidate.startsWith('public/') ? candidate : path.posix.basename(candidate);
if (normalized.startsWith('public/')) targets.add(normalized);
}
}
return [...targets];
}
export function evaluatePageDataFinishGuard({
publishDir,
agentText = '',
messages = [],
requestStartedAt = 0,
} = {}) {
const pageDataIntent = isPageDataIntent(agentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
const relevantFiles = pageDataFiles.filter((file) =>
pageDataIntent || recentWrites.includes(file.relativePath),
);
const htmlIssues = relevantFiles.flatMap((file) =>
file.evaluation.issues.map((issue) => ({
issue,
relativePath: file.relativePath,
})),
);
const unboundFiles = relevantFiles.filter(
(file) =>
file.evaluation.usage?.size > 0 &&
!pageHasBoundPolicy({
publishDir,
relativePath: file.relativePath,
html: file.content,
}),
);
const needsRepair =
pageDataIntent &&
(htmlIssues.length > 0 ||
unboundFiles.length > 0 ||
(relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages)));
return {
pageDataIntent,
relevantFiles,
htmlIssues,
unboundFiles,
needsRepair,
usedSkill: usedPageDataCollectSkill(messages),
usedBindTool: usedPageDataBindTool(messages),
};
}
export function buildPageDataCollectFailureText() {
return [
'这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。',
'请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:',
'建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。',
'数据必须走平台 API 写入 SQLite,禁止 localStorage 或自建后端。',
].join('');
}
export function buildPageDataCollectRepairPrompt({
htmlIssues = [],
unboundFiles = [],
} = {}) {
const lines = [
'【系统补绑请求】检测到 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',
'4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888',
];
if (htmlIssues.length) {
lines.push('', 'HTML 问题:');
for (const item of htmlIssues) {
lines.push(`- ${item.relativePath}: ${item.issue}`);
}
}
if (unboundFiles.length) {
lines.push('', '尚未 bind 的 Page Data 页面:');
for (const file of unboundFiles) {
lines.push(`- ${file.relativePath}`);
}
}
lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。');
return lines.join('\n');
}
export function shouldRetryPageDataCollectReply({
reply,
intent,
publishDir,
confirmedArtifacts = [],
requestStartedAt = 0,
}) {
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
if (!isPageDataIntent(agentText)) return false;
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText,
messages: reply?.messages ?? [],
requestStartedAt,
});
if (!evaluation.pageDataIntent) return false;
if (evaluation.htmlIssues.length > 0) return true;
if (evaluation.unboundFiles.length > 0) return true;
const wrotePageDataHtml = evaluation.relevantFiles.length > 0;
const wroteAnyHtml = confirmedArtifacts.length > 0 || extractRecentPageDataHtmlWrites(reply?.messages ?? [], { sinceMs: requestStartedAt }).length > 0;
if (wroteAnyHtml && !usedPageDataCollectSkill(reply?.messages ?? []) && wrotePageDataHtml) {
return true;
}
const replyClaimsDone = /(?:问卷|报名|后台|数据).*(?:已创建|已生成|已发布|可以提交|完成)/iu.test(String(reply?.text ?? ''));
if (replyClaimsDone && evaluation.unboundFiles.length > 0) return true;
return false;
}
export function resolvePageDataCollectOutcome({
reply,
intent,
publishDir,
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.htmlIssues.length > 0) {
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
}
if (evaluation.unboundFiles.length > 0) {
return { action: 'retry', reason: 'missing_bind', evaluation };
}
if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) {
return { action: 'retry', reason: 'incomplete_delivery', evaluation };
}
return { action: 'send', evaluation };
}
export function isPageDataFinishGuardEnabled(env = process.env) {
return envFlag(env.MEMIND_PAGE_DATA_FINISH_GUARD, true);
}
export function resetPageDataFinishGuardAttempts(sessionId = null) {
if (sessionId) repairAttemptsBySession.delete(String(sessionId));
else repairAttemptsBySession.clear();
}
export async function maybeAutoBindPageDataHtmlPages({
pool,
userId,
publishDir,
h5Root,
storageRoot,
onlyRelativePaths = null,
} = {}) {
if (!pool) {
return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] };
}
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;
}
if (pageHasBoundPolicy({
publishDir,
relativePath: file.relativePath,
html: file.content,
})) {
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 };
}
export async function maybeRepairPageDataAfterFinish({
sessionId,
userId,
publishDir,
messages,
pool,
h5Root,
storageRoot,
tkmindProxy = null,
maxAttempts = 1,
env = process.env,
logger = console,
userText = '',
} = {}) {
const recentUserText = String(userText ?? '').trim();
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText: recentUserText,
messages,
});
if (!evaluation.pageDataIntent && evaluation.relevantFiles.length === 0) {
resetPageDataFinishGuardAttempts(sessionId);
return { repaired: false, skipped: 'not_page_data', ...evaluation };
}
const autoBind = await maybeAutoBindPageDataHtmlPages({
pool,
userId,
publishDir,
h5Root,
storageRoot,
onlyRelativePaths: evaluation.relevantFiles.map((file) => file.relativePath),
});
const afterBind = evaluatePageDataFinishGuard({
publishDir,
agentText: recentUserText,
messages,
});
if (!afterBind.needsRepair) {
resetPageDataFinishGuardAttempts(sessionId);
return { repaired: autoBind.bound.length > 0, skipped: 'ok', autoBind, ...afterBind };
}
if (!isPageDataFinishGuardEnabled(env)) {
logger.warn?.(
`[MindSpace][page-data-guard] incomplete page data delivery for session ${sessionId}: `
+ `issues=${afterBind.htmlIssues.map((item) => `${item.relativePath}:${item.issue}`).join(',') || 'none'} `
+ `unbound=${afterBind.unboundFiles.map((file) => file.relativePath).join(',') || 'none'}`,
);
return { repaired: false, skipped: 'disabled', autoBind, ...afterBind };
}
if (!tkmindProxy?.submitSessionReplyForUser) {
return { repaired: false, skipped: 'no_proxy', autoBind, ...afterBind };
}
const key = String(sessionId ?? '');
const attempts = repairAttemptsBySession.get(key) ?? 0;
if (!key || attempts >= maxAttempts) {
logger.warn?.(
`[MindSpace][page-data-guard] repair limit reached for session ${sessionId}`,
afterBind,
);
return { repaired: false, skipped: 'limit', attempts, autoBind, ...afterBind };
}
repairAttemptsBySession.set(key, attempts + 1);
const prompt = buildPageDataCollectRepairPrompt(afterBind);
const requestId = `page-data-repair-${crypto.randomUUID()}`;
const userMessage = {
role: 'user',
content: [{ type: 'text', text: prompt }],
metadata: {
displayText: '请补全 Page Data 问卷绑定与页面脚本',
userVisible: false,
agentVisible: true,
memindRun: { pageDataFinishRepair: true },
},
};
try {
await tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage);
logger.info?.(
`[MindSpace][page-data-guard] triggered repair for session ${sessionId} `
+ `(attempt ${attempts + 1}/${maxAttempts})`,
);
return { repaired: true, triggered: true, attempts: attempts + 1, autoBind, ...afterBind };
} catch (err) {
logger.warn?.(
`[MindSpace][page-data-guard] repair failed for session ${sessionId}: `
+ `${err instanceof Error ? err.message : err}`,
);
return {
repaired: false,
skipped: 'error',
error: err instanceof Error ? err.message : String(err),
attempts: attempts + 1,
autoBind,
...afterBind,
};
}
}
+169
View File
@@ -0,0 +1,169 @@
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 {
buildPageDataCollectFailureText,
buildPageDataCollectRepairPrompt,
collectPageDataDeliveryArtifacts,
evaluatePageDataFinishGuard,
evaluatePageDataHtmlContent,
inferPageDataBindAccessMode,
maybeAutoBindPageDataHtmlPages,
rewritePageDataDeliveryLinks,
shouldRetryPageDataCollectReply,
} from './mindspace-page-data-finish-guard.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
const SURVEY_HTML = `<!doctype html><html><head><title>问卷</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', {});
</script></body></html>`;
const BAD_SURVEY_HTML = `<!doctype html><html><body><script>
async function save(data) {
if (typeof MindSpacePageData !== 'undefined') {
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
return;
}
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
}
</script></body></html>`;
test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => {
const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' });
assert.deepEqual(ok.issues, []);
const bad = evaluatePageDataHtmlContent(BAD_SURVEY_HTML, { relativePath: 'public/children-diet-survey.html' });
assert.ok(bad.issues.includes('missing_page_data_client_script'));
assert.ok(bad.issues.includes('forbidden_local_storage'));
});
test('inferPageDataBindAccessMode chooses password for admin html', () => {
assert.equal(
inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML),
'password',
);
assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public');
});
test('evaluatePageDataFinishGuard detects unbound page data html', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText: '帮我设计一个调查问卷,要加一个后台',
messages: [],
});
assert.equal(evaluation.pageDataIntent, true);
assert.equal(evaluation.unboundFiles.length, 1);
assert.equal(evaluation.needsRepair, true);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('shouldRetryPageDataCollectReply retries when localStorage fallback exists', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-retry-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
assert.equal(
shouldRetryPageDataCollectReply({
reply: { text: '问卷已发布', messages: [] },
intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
publishDir,
}),
true,
);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('buildPageDataCollectRepairPrompt mentions bind and forbids localStorage', () => {
const prompt = buildPageDataCollectRepairPrompt({
htmlIssues: [{ relativePath: 'public/x.html', issue: 'forbidden_local_storage' }],
unboundFiles: [{ relativePath: 'public/x.html' }],
});
assert.match(prompt, /private_data_bind_workspace_page/);
assert.match(prompt, /localStorage/);
assert.match(buildPageDataCollectFailureText(), /page-data-collect/);
});
test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bind-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'bad.html'), BAD_SURVEY_HTML, 'utf8');
const result = await maybeAutoBindPageDataHtmlPages({
pool: null,
userId: 'user-1',
publishDir,
h5Root: publishDir,
storageRoot: publishDir,
});
assert.equal(result.bound.length, 0);
assert.equal(result.errors[0]?.code, 'database_unconfigured');
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
test('evaluatePageDataFinishGuard passes when policy already exists', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bound-'));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
writePageAccessPolicy(publishDir, {
pageId: 'page-1',
ownerUserId: 'user-1',
accessMode: 'public',
datasets: {
diet_survey: {
insert: true,
read: false,
columns: { insert: ['child_age'] },
},
},
});
const evaluation = evaluatePageDataFinishGuard({
publishDir,
agentText: '调查问卷和后台',
messages: [],
});
assert.equal(evaluation.unboundFiles.length, 0);
assert.equal(evaluation.htmlIssues.length, 0);
} finally {
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;
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(path.join(publishDir, 'public', 'zhiqu-survey.html'), SURVEY_HTML, 'utf8');
fs.writeFileSync(path.join(publishDir, 'public', 'zhiqu-survey-admin.html'), SURVEY_HTML, 'utf8');
const artifacts = collectPageDataDeliveryArtifacts(publishDir);
assert.equal(artifacts.length, 2);
const text = [
'问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
'后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
].join('\n');
const next = rewritePageDataDeliveryLinks(text, artifacts);
assert.doesNotMatch(next, /\/u\/john\/pages\//);
assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey\.html/);
assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey-admin\.html/);
} finally {
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
else process.env.H5_PUBLIC_BASE_URL = previousBase;
fs.rmSync(publishDir, { recursive: true, force: true });
}
});
+16 -19
View File
@@ -176,14 +176,12 @@ function resolvePublicationDeliveryPublicUrl({
privateToken = null,
} = {}) {
if (privateToken) return `/s/${privateToken}`;
if (accessMode === 'public') {
const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({
h5Root,
userId,
relativePath: workspaceRelativePath,
});
if (mindSpaceUrl) return mindSpaceUrl;
}
const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({
h5Root,
userId,
relativePath: workspaceRelativePath,
});
if (mindSpaceUrl) return mindSpaceUrl;
return buildOnlinePublicationPublicUrl(ownerSlug, urlSlug);
}
@@ -1035,17 +1033,16 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ?`,
[htmlBytes, checksum, publication.asset_version_id],
);
let publicUrl = publication.public_url;
if (publication.access_mode === 'public') {
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
publicUrl = resolvePublicationDeliveryPublicUrl({
h5Root,
userId,
ownerSlug,
urlSlug: publication.url_slug,
accessMode: publication.access_mode,
workspaceRelativePath,
});
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
const publicUrl = resolvePublicationDeliveryPublicUrl({
h5Root,
userId,
ownerSlug,
urlSlug: publication.url_slug,
accessMode: publication.access_mode,
workspaceRelativePath,
});
if (publicUrl !== publication.public_url) {
await pool.query(
`UPDATE h5_publish_records SET public_url = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
[publicUrl, now, publication.id, userId],
+1
View File
@@ -30,6 +30,7 @@ test('createMindSpaceServerAdapter returns a contract-complete local adapter by
maxFileBytes: 4096,
publicPageLimit: 3,
resolveUserIdForAgentSession: async () => 'user-1',
resolveWorkspaceRoot: async () => '/tmp/workspace',
logger: { log() {}, warn() {}, error() {} },
});
+11
View File
@@ -22,7 +22,18 @@ The H5 APIs are reused as-is. Native Mini Program login posts the `wx.login` cod
- `utils/config.js` 默认 `LOCAL_DEV = false`API 指向 **`https://m.tkmind.cn`**。
- `project.config.json``urlCheck: true`;DevTools 需配置真实小程序 AppID(非 tourist)。
- 微信公众平台需配置 request / web-view 合法域名:`m.tkmind.cn`
- 已启用 `__usePrivacyCheck__`,登录页含协议勾选(默认不勾选)、协议全文页与微信隐私弹窗组件。
- 首次登录须主动勾选协议;登录成功后本地保存 cookie 与同意记录,下次自动登录不再展示协议区。
- 此配置**不会**触发 103 发版;仅小程序客户端连线上 Portal。
### 微信公众平台后台(提审必配)
1. **设置 → 服务内容声明 → 用户隐私保护指引**
- 声明收集:用户账号、邮箱、微信登录标识、聊天记录、设备信息
- 用途:账号登录验证、身份识别、提供 AI 对话与会话服务
2. **设置 → 基本设置 → 服务内容声明**
- 补充用户服务协议与隐私政策说明(协议已内置在小程序 `pages/legal/`
3. 提审备注可写:「登录页已增加协议勾选与隐私政策全文,未同意前不提交用户信息;已接入微信隐私保护检测」
- **微信一键登录**还需 Portal 侧配置 `H5_WECHAT_MINIAPP_APP_ID` / `H5_WECHAT_MINIAPP_APP_SECRET` 并实现 `/auth/wechat-miniapp/login`;未部署前可先用账号密码登录。
### 开发者工具仍显示「游客模式」时
+4 -1
View File
@@ -1,12 +1,14 @@
const { getStoredSession, setApiBaseUrl, getMiniProgramAppId, isTouristMode, getApiBaseUrl } = require('./utils/api');
const { API_BASE_URL } = require('./utils/config');
const { getPrivacySetting } = require('./utils/privacy');
App({
globalData: {
user: null,
privacySetting: null,
},
onLaunch() {
async onLaunch() {
setApiBaseUrl(API_BASE_URL);
const appId = getMiniProgramAppId();
console.info('[TKMind miniapp] API_BASE_URL =', getApiBaseUrl() || API_BASE_URL);
@@ -18,5 +20,6 @@ App({
if (session?.user) {
this.globalData.user = session.user;
}
this.globalData.privacySetting = await getPrivacySetting();
},
});
+5 -1
View File
@@ -4,8 +4,12 @@
"pages/chat/index",
"pages/sessions/index",
"pages/space/index",
"pages/webview/index"
"pages/webview/index",
"pages/legal/index/index",
"pages/legal/privacy/index",
"pages/legal/terms/index"
],
"__usePrivacyCheck__": true,
"window": {
"navigationBarTitleText": "TKMind",
"navigationBarBackgroundColor": "#101820",
+45
View File
@@ -0,0 +1,45 @@
const { openPrivacyContract } = require('../../utils/privacy');
Component({
data: {
show: false,
privacyContractName: '《用户隐私保护指引》',
},
lifetimes: {
attached() {
if (typeof wx.onNeedPrivacyAuthorization !== 'function') {
return;
}
wx.onNeedPrivacyAuthorization((resolve) => {
this._privacyResolve = resolve;
this.setData({ show: true });
});
},
},
methods: {
openPrivacyContract() {
openPrivacyContract();
},
handleDisagree() {
this.setData({ show: false });
if (this._privacyResolve) {
this._privacyResolve({ event: 'disagree' });
this._privacyResolve = null;
}
},
handleAgree() {
this.setData({ show: false });
if (this._privacyResolve) {
this._privacyResolve({
buttonId: 'agree-privacy-btn',
event: 'agree',
});
this._privacyResolve = null;
}
},
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,21 @@
<view wx:if="{{show}}" class="privacy-mask">
<view class="privacy-panel">
<text class="privacy-title">用户隐私保护提示</text>
<view class="privacy-body">
<text>在使用 TKMind 前,请阅读</text>
<text class="privacy-link" bindtap="openPrivacyContract">{{privacyContractName}}</text>
<text>。点击「同意」即表示你已阅读并同意该指引。</text>
</view>
<view class="privacy-actions">
<button class="privacy-decline" bindtap="handleDisagree">拒绝</button>
<button
id="agree-privacy-btn"
class="privacy-accept"
open-type="agreePrivacyAuthorization"
bindagreeprivacyauthorization="handleAgree"
>
同意
</button>
</view>
</view>
</view>
@@ -0,0 +1,65 @@
.privacy-mask {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(15, 23, 42, 0.55);
box-sizing: border-box;
}
.privacy-panel {
width: 100%;
max-width: 640rpx;
padding: 36rpx;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.12);
}
.privacy-title {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #101820;
}
.privacy-body {
margin-top: 24rpx;
font-size: 26rpx;
color: #334155;
line-height: 1.8;
}
.privacy-link {
color: #0f766e;
font-weight: 600;
}
.privacy-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.privacy-decline,
.privacy-accept {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 700;
line-height: 80rpx;
}
.privacy-decline {
background: #f1f5f9;
color: #475569;
}
.privacy-accept {
background: #0f766e;
color: #ffffff;
}
+4
View File
@@ -206,6 +206,10 @@ Page({
this.setData({ sessionId: '', messages: [], draft: '', error: '' });
},
openLegalCenter() {
wx.navigateTo({ url: '/pages/legal/index/index' });
},
openLink(event) {
let url = String(event.currentTarget.dataset.url || '').trim();
if (!url) {
+4 -1
View File
@@ -1,3 +1,6 @@
{
"navigationBarTitleText": "TKMind 聊天"
"navigationBarTitleText": "TKMind 聊天",
"usingComponents": {
"privacy-popup": "/components/privacy-popup/index"
}
}
+3
View File
@@ -6,6 +6,7 @@
</view>
<view class="header-actions">
<button class="header-button" bindtap="startNewChat">新聊天</button>
<button class="header-button" bindtap="openLegalCenter">隐私</button>
<button class="header-button" bindtap="handleLogout">退出</button>
</view>
</view>
@@ -71,3 +72,5 @@
<button class="send" loading="{{loading}}" disabled="{{loading}}" bindtap="sendMessage">发送</button>
</view>
</view>
<privacy-popup />
+15
View File
@@ -0,0 +1,15 @@
const { openPrivacyContract } = require('../../../utils/privacy');
Page({
openTerms() {
wx.navigateTo({ url: '/pages/legal/terms/index' });
},
openPrivacy() {
wx.navigateTo({ url: '/pages/legal/privacy/index' });
},
openWechatPrivacy() {
openPrivacyContract();
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "隐私与协议"
}
+23
View File
@@ -0,0 +1,23 @@
<view class="page legal-hub-page">
<view class="panel legal-hub-card">
<text class="legal-hub-title">隐私与协议</text>
<text class="legal-hub-desc">
你可以随时查看 TKMind 的用户服务协议与隐私政策,了解我们如何收集、使用与保护你的个人信息。
</text>
<view class="legal-hub-item" bindtap="openTerms">
<text class="legal-hub-item-title">用户服务协议</text>
<text class="legal-hub-item-desc">服务范围、账号使用规范与免责声明</text>
</view>
<view class="legal-hub-item" bindtap="openPrivacy">
<text class="legal-hub-item-title">隐私政策</text>
<text class="legal-hub-item-desc">个人信息收集目的、方式、用途与您的权利</text>
</view>
<view class="legal-hub-item" bindtap="openWechatPrivacy">
<text class="legal-hub-item-title">微信用户隐私保护指引</text>
<text class="legal-hub-item-desc">查看微信平台提供的隐私保护说明</text>
</view>
</view>
</view>
+44
View File
@@ -0,0 +1,44 @@
.legal-hub-page {
padding-top: 24rpx;
}
.legal-hub-card {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 32rpx;
}
.legal-hub-title {
font-size: 40rpx;
font-weight: 800;
color: #101820;
}
.legal-hub-desc {
font-size: 26rpx;
color: #475569;
line-height: 1.8;
}
.legal-hub-item {
padding: 24rpx;
border: 1rpx solid #dbe3eb;
border-radius: 12rpx;
background: #f8fafc;
}
.legal-hub-item-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #0f766e;
}
.legal-hub-item-desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
line-height: 1.6;
}
+1
View File
@@ -0,0 +1 @@
Page({});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "隐私政策"
}
+87
View File
@@ -0,0 +1,87 @@
<view class="page legal-page">
<view class="panel legal-card">
<text class="legal-title">TKMind 隐私政策</text>
<text class="legal-meta">更新日期:2026年7月12日 · 生效日期:2026年7月12日</text>
<view class="legal-section">
<text class="legal-heading">引言</text>
<text class="legal-paragraph">
TKMind(以下简称「我们」)重视您的个人信息保护。本隐私政策说明我们在您使用 TKMind 微信小程序(以下简称「本小程序」)时,如何收集、使用、存储与保护您的个人信息,以及您享有的相关权利。
</text>
<text class="legal-paragraph">
在您点击同意本政策并使用登录功能前,我们不会收集您的账号、邮箱、密码或微信登录凭证。请您在使用本小程序前仔细阅读并充分理解本政策。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">一、我们收集的信息</text>
<text class="legal-paragraph">
为实现账号登录与核心功能,我们将在您同意本政策后,收集以下个人信息:
</text>
<text class="legal-item">1. 账号与邮箱信息:您主动填写的用户名、邮箱地址,用于账号识别、登录验证与账号找回。</text>
<text class="legal-item">2. 登录凭证:您输入的登录密码仅用于身份验证,经加密传输,不在客户端明文保存。</text>
<text class="legal-item">3. 微信登录信息:当您选择「微信一键登录」时,我们会通过微信提供的临时登录凭证(code)换取您的微信用户标识(如 openid),用于完成身份验证与账号绑定。</text>
<text class="legal-item">4. 会话与业务数据:您与 AI 的对话内容、会话记录、MindSpace 页面访问记录,以及您主动提交的业务数据。</text>
<text class="legal-item">5. 设备与日志信息:为保障服务安全与稳定,我们可能记录设备型号、操作系统版本、网络类型、操作时间、接口请求日志等必要技术信息。</text>
</view>
<view class="legal-section">
<text class="legal-heading">二、信息的使用目的与方式</text>
<text class="legal-paragraph">我们仅在以下目的范围内使用您的个人信息:</text>
<text class="legal-item">1. 身份验证:核验您的登录身份,建立并维持您的账号会话。</text>
<text class="legal-item">2. 提供服务:向您提供 AI 对话、会话管理、MindSpace 页面浏览等核心功能。</text>
<text class="legal-item">3. 安全保障:识别异常登录、防范欺诈与滥用,保障系统与数据安全。</text>
<text class="legal-item">4. 服务改进:在符合法律法规的前提下,用于故障排查、性能优化与体验改进。</text>
<text class="legal-paragraph">
我们不会将您的个人信息用于与本政策所述目的无关的用途。如需用于其他目的,我们将另行征得您的同意。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">三、信息的存储与保护</text>
<text class="legal-item">1. 您的个人信息将存储于中华人民共和国境内的安全服务器,并采取访问控制、传输加密等合理安全措施。</text>
<text class="legal-item">2. 我们仅在实现本政策所述目的所必需的期限内保留您的个人信息,法律法规另有规定的除外。</text>
<text class="legal-item">3. 如发生安全事件,我们将依法及时告知您并采取补救措施。</text>
</view>
<view class="legal-section">
<text class="legal-heading">四、信息的共享与披露</text>
<text class="legal-paragraph">
我们不会向第三方出售您的个人信息。仅在以下情形中,我们可能会共享或披露相关信息:
</text>
<text class="legal-item">1. 事先获得您的明确同意。</text>
<text class="legal-item">2. 根据法律法规、司法机关或政府主管部门的强制性要求。</text>
<text class="legal-item">3. 为履行本政策所述服务目的,向受严格保密义务约束的服务提供方(如基础设施托管方)提供必要信息。</text>
</view>
<view class="legal-section">
<text class="legal-heading">五、您的权利</text>
<text class="legal-paragraph">您依法享有以下权利:</text>
<text class="legal-item">1. 查询与访问:您可登录后查看账号基本信息及会话数据。</text>
<text class="legal-item">2. 更正与补充:如发现信息有误,可通过应用内功能或联系我们进行更正。</text>
<text class="legal-item">3. 删除与注销:您可申请删除特定数据或注销账号;注销后我们将停止提供服务并依法删除或匿名化处理相关个人信息。</text>
<text class="legal-item">4. 撤回同意:您可通过停止使用本小程序、退出登录等方式撤回授权;撤回后我们将不再处理相应个人信息,但不影响撤回前基于您同意已进行的处理。</text>
</view>
<view class="legal-section">
<text class="legal-heading">六、未成年人保护</text>
<text class="legal-paragraph">
若您为未成年人,请在监护人陪同下阅读本政策,并在取得监护人同意后使用本小程序。我们不会主动面向未满十四周岁的未成年人收集个人信息。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">七、政策更新</text>
<text class="legal-paragraph">
我们可能适时修订本政策。重大变更时,我们将通过小程序内提示等方式通知您。若您继续使用本小程序,即表示您同意更新后的政策。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">八、联系我们</text>
<text class="legal-paragraph">
如您对本隐私政策或个人信息处理有任何疑问、意见或投诉,请通过 TKMind 官方渠道(m.tkmind.cn)或小程序内反馈入口与我们联系,我们将在合理期限内予以答复。
</text>
</view>
</view>
</view>
+47
View File
@@ -0,0 +1,47 @@
.legal-page {
padding-bottom: 48rpx;
}
.legal-card {
display: flex;
flex-direction: column;
gap: 28rpx;
padding: 32rpx;
}
.legal-title {
font-size: 40rpx;
font-weight: 800;
color: #101820;
line-height: 1.4;
}
.legal-meta {
font-size: 24rpx;
color: #64748b;
line-height: 1.5;
}
.legal-section {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.legal-heading {
font-size: 30rpx;
font-weight: 700;
color: #132028;
line-height: 1.5;
}
.legal-paragraph,
.legal-item {
font-size: 26rpx;
color: #334155;
line-height: 1.8;
}
.legal-item {
padding-left: 8rpx;
}
+1
View File
@@ -0,0 +1 @@
Page({});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "用户服务协议"
}
+92
View File
@@ -0,0 +1,92 @@
<view class="page legal-page">
<view class="panel legal-card">
<text class="legal-title">TKMind 用户服务协议</text>
<text class="legal-meta">更新日期:2026年7月12日 · 生效日期:2026年7月12日</text>
<view class="legal-section">
<text class="legal-heading">一、协议范围</text>
<text class="legal-paragraph">
本协议是您与 TKMind 运营方之间,就您使用 TKMind 微信小程序(以下简称「本小程序」)所订立的法律协议。请您在注册、登录或使用本小程序前仔细阅读本协议。您点击同意或实际使用本小程序,即视为您已阅读并同意受本协议约束。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">二、服务内容</text>
<text class="legal-paragraph">本小程序向您提供包括但不限于以下服务:</text>
<text class="legal-item">1. AI 智能对话与助手服务;</text>
<text class="legal-item">2. 会话历史管理与查看;</text>
<text class="legal-item">3. MindSpace 页面浏览与预览;</text>
<text class="legal-item">4. 微信一键登录及 H5 账号登录等身份验证服务。</text>
<text class="legal-paragraph">
我们有权根据业务发展需要调整服务内容,并将通过合理方式告知您。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">三、账号注册与使用</text>
<text class="legal-item">1. 您应使用真实、合法、有效的账号信息完成登录,并妥善保管账号与密码,不得将账号转让、出借或供他人使用。</text>
<text class="legal-item">2. 您应对使用本账号进行的所有操作行为负责。如发现账号被盗用或存在安全风险,请立即联系我们。</text>
<text class="legal-item">3. 使用微信一键登录时,您授权我们依据微信平台规则获取完成登录所必需的用户标识信息。</text>
</view>
<view class="legal-section">
<text class="legal-heading">四、用户行为规范</text>
<text class="legal-paragraph">您在使用本小程序时,不得从事以下行为:</text>
<text class="legal-item">1. 发布、传播违反法律法规、公序良俗或侵犯他人合法权益的内容;</text>
<text class="legal-item">2. 利用本小程序从事欺诈、骚扰、网络攻击或其他危害网络安全的行为;</text>
<text class="legal-item">3. 未经授权访问、干扰或破坏本小程序及相关系统;</text>
<text class="legal-item">4. 利用技术手段批量注册、滥用接口或干扰服务正常运行。</text>
<text class="legal-paragraph">
如您违反上述规定,我们有权采取警告、限制功能、暂停或终止服务等措施,并依法保留追究法律责任的权利。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">五、个人信息保护</text>
<text class="legal-paragraph">
我们重视您的个人信息保护。关于个人信息的收集、使用、存储方式及您的权利,请详见《隐私政策》。您使用本小程序即表示您已阅读并同意《隐私政策》。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">六、知识产权</text>
<text class="legal-paragraph">
本小程序的软件、界面设计、商标及相关内容的知识产权归 TKMind 运营方或相关权利人所有。未经授权,您不得复制、修改、传播或用于商业用途。
</text>
<text class="legal-paragraph">
您通过本小程序生成或上传的内容,其权利归属依照相关法律法规及您与我们的约定确定。您应确保对所提交内容拥有合法权利,不侵犯第三方权益。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">七、免责声明</text>
<text class="legal-item">1. AI 生成内容仅供参考,不构成专业建议(包括但不限于医疗、法律、投资等领域建议),您应自行判断并承担使用风险。</text>
<text class="legal-item">2. 因不可抗力、网络故障、第三方服务中断或您自身原因导致的服务中断或数据损失,我们在法律允许范围内不承担责任。</text>
<text class="legal-item">3. 我们将尽力保障服务稳定,但不保证服务持续无中断、无错误或完全满足您的特定需求。</text>
</view>
<view class="legal-section">
<text class="legal-heading">八、协议变更与终止</text>
<text class="legal-paragraph">
我们有权根据法律法规或业务需要修订本协议,并通过小程序内公告等方式通知您。若您不同意变更内容,可停止使用本小程序;若您继续使用,则视为接受变更后的协议。
</text>
<text class="legal-paragraph">
您可随时停止使用本小程序。我们亦可在您严重违反本协议时终止向您提供服务。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">九、适用法律与争议解决</text>
<text class="legal-paragraph">
本协议的订立、执行与解释适用中华人民共和国法律。因本协议引起的争议,双方应友好协商解决;协商不成的,任何一方可向 TKMind 运营方所在地有管辖权的人民法院提起诉讼。
</text>
</view>
<view class="legal-section">
<text class="legal-heading">十、联系我们</text>
<text class="legal-paragraph">
如您对本协议有任何疑问,请通过 TKMind 官方渠道(m.tkmind.cn)或小程序内反馈入口与我们联系。
</text>
</view>
</view>
</view>
+1
View File
@@ -0,0 +1 @@
@import '../privacy/index.wxss';
+77 -2
View File
@@ -3,9 +3,19 @@ const {
loginWithPassword,
loginWithWechatCode,
isTouristMode,
getApiBaseUrl
getApiBaseUrl,
getStoredSession
} = require('../../utils/api');
const { MINIAPP_LOGIN_PATH } = require('../../utils/config');
const {
saveLegalConsent,
shouldSkipLoginConsent
} = require('../../utils/legal-consent');
function hasStoredSessionCookie() {
const session = getStoredSession();
return Boolean(session?.cookie);
}
Page({
data: {
@@ -15,10 +25,18 @@ Page({
wechatLoading: false,
passwordLoading: false,
touristWarning: '',
envHint: ''
envHint: '',
agreedToLegal: false,
showLegalConsent: true,
showConsentModal: false
},
onLoad() {
const skipConsent = hasStoredSessionCookie() && shouldSkipLoginConsent();
this.setData({
agreedToLegal: false,
showLegalConsent: !skipConsent
});
this.refreshEnvHints();
this.tryAutoLogin();
},
@@ -40,6 +58,9 @@ Page({
},
async tryAutoLogin() {
if (!hasStoredSessionCookie()) {
return;
}
try {
const status = await checkAuth();
if (status?.authenticated) {
@@ -62,10 +83,61 @@ Page({
this.setData({ password: event.detail.value, error: '' });
},
handleConsentChange(event) {
const agreedToLegal = (event.detail.value || []).includes('agreed');
this.setData({ agreedToLegal, error: '' });
},
openTerms() {
wx.navigateTo({ url: '/pages/legal/terms/index' });
},
openPrivacy() {
wx.navigateTo({ url: '/pages/legal/privacy/index' });
},
openLegalCenter() {
wx.navigateTo({ url: '/pages/legal/index/index' });
},
ensureLegalConsent() {
if (!this.data.showLegalConsent || this.data.agreedToLegal) {
return true;
}
this.setData({
error: '请先阅读并同意《用户服务协议》和《隐私政策》后再登录',
showConsentModal: true
});
return false;
},
acceptConsentModal() {
this.setData({
agreedToLegal: true,
showConsentModal: false,
error: ''
});
},
declineConsentModal() {
this.setData({ showConsentModal: false });
wx.showToast({
title: '需同意协议后方可使用',
icon: 'none'
});
},
persistConsentAfterLogin() {
saveLegalConsent();
},
async handleWechatLogin() {
if (!this.ensureLegalConsent()) return;
this.setData({ wechatLoading: true, error: '' });
try {
await loginWithWechatCode(MINIAPP_LOGIN_PATH);
this.persistConsentAfterLogin();
wx.switchTab({ url: '/pages/chat/index' });
} catch (error) {
this.setData({
@@ -79,6 +151,8 @@ Page({
},
async handlePasswordLogin() {
if (!this.ensureLegalConsent()) return;
const username = this.data.username.trim();
const password = this.data.password;
if (!username || !password) {
@@ -88,6 +162,7 @@ Page({
this.setData({ passwordLoading: true, error: '' });
try {
await loginWithPassword(username, password);
this.persistConsentAfterLogin();
wx.switchTab({ url: '/pages/chat/index' });
} catch (error) {
this.setData({ error: error?.message || '登录失败,请重试' });
+4 -1
View File
@@ -1,3 +1,6 @@
{
"navigationBarTitleText": "登录 TKMind"
"navigationBarTitleText": "登录 TKMind",
"usingComponents": {
"privacy-popup": "/components/privacy-popup/index"
}
}
+58 -2
View File
@@ -8,7 +8,11 @@
<view wx:if="{{touristWarning}}" class="warn-box">{{touristWarning}}</view>
<view wx:if="{{envHint}}" class="info-box">{{envHint}}</view>
<button class="primary-button" loading="{{wechatLoading}}" bindtap="handleWechatLogin">
<button
class="primary-button"
loading="{{wechatLoading}}"
bindtap="handleWechatLogin"
>
微信一键登录
</button>
@@ -16,10 +20,62 @@
<input class="field" placeholder="用户名 / 邮箱" value="{{username}}" bindinput="handleUsername" />
<input class="field" placeholder="密码" password value="{{password}}" bindinput="handlePassword" />
<button class="secondary-button" loading="{{passwordLoading}}" bindtap="handlePasswordLogin">
<button
class="secondary-button"
loading="{{passwordLoading}}"
bindtap="handlePasswordLogin"
>
账号登录
</button>
<view wx:if="{{showLegalConsent}}" class="consent-box">
<checkbox-group bindchange="handleConsentChange">
<label class="consent-row">
<checkbox value="agreed" checked="{{agreedToLegal}}" color="#0f766e" />
<view class="consent-text">
<text>我已阅读并同意</text>
<text class="consent-link" catchtap="openTerms">《用户服务协议》</text>
<text>和</text>
<text class="consent-link" catchtap="openPrivacy">《隐私政策》</text>
<text>,并授权 TKMind 为账号登录、身份验证及提供 AI 对话服务,收集和使用我的用户名、邮箱、登录密码(加密传输)及微信登录标识。</text>
</view>
</label>
</checkbox-group>
</view>
<text wx:if="{{error}}" class="error">{{error}}</text>
</view>
<view class="legal-footer">
<text class="consent-link" bindtap="openTerms">用户服务协议</text>
<text class="legal-sep">|</text>
<text class="consent-link" bindtap="openPrivacy">隐私政策</text>
<text class="legal-sep">|</text>
<text class="consent-link" bindtap="openLegalCenter">隐私与协议</text>
</view>
</view>
<view wx:if="{{showConsentModal}}" class="consent-modal-mask">
<view class="consent-modal panel">
<text class="consent-modal-title">个人信息收集说明</text>
<text class="consent-modal-body">
为完成账号登录并提供 AI 对话、会话管理等服务,TKMind 将收集以下信息:用户名/邮箱、登录密码(加密传输)、微信登录标识、会话与聊天记录。
</text>
<text class="consent-modal-body">
收集目的:账号验证、身份识别、提供服务与安全保障。收集方式:由您主动填写或授权微信登录后获取。使用范围:仅用于本小程序及相关服务,不会出售给第三方。
</text>
<text class="consent-modal-body">
请阅读
<text class="consent-link" bindtap="openTerms">《用户服务协议》</text>
<text class="consent-link" bindtap="openPrivacy">《隐私政策》</text>
后勾选同意。仅在您同意后,我们才会处理相关个人信息。
</text>
<view class="consent-modal-actions">
<button class="consent-decline-button" bindtap="declineConsentModal">暂不同意</button>
<button class="consent-accept-button" bindtap="acceptConsentModal">我已阅读并同意</button>
</view>
</view>
</view>
<privacy-popup />
+95
View File
@@ -70,3 +70,98 @@
color: #1d4f91;
border: 1rpx solid #c7ddff;
}
.consent-box {
padding: 8rpx 0;
}
.consent-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.consent-text {
flex: 1;
font-size: 24rpx;
color: #475569;
line-height: 1.7;
}
.consent-link {
color: #0f766e;
font-weight: 600;
}
.consent-modal-mask {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(15, 23, 42, 0.55);
box-sizing: border-box;
}
.consent-modal {
display: flex;
flex-direction: column;
gap: 24rpx;
width: 100%;
max-width: 640rpx;
padding: 36rpx;
}
.consent-modal-title {
font-size: 34rpx;
font-weight: 800;
color: #101820;
}
.consent-modal-body {
font-size: 26rpx;
color: #334155;
line-height: 1.8;
}
.consent-modal-actions {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
}
.consent-decline-button,
.consent-accept-button {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 700;
line-height: 80rpx;
}
.consent-decline-button {
background: #f1f5f9;
color: #475569;
}
.consent-accept-button {
background: #0f766e;
color: #ffffff;
}
.legal-footer {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 12rpx;
font-size: 24rpx;
color: #64748b;
}
.legal-sep {
color: #cbd5e1;
}
+2
View File
@@ -1,5 +1,6 @@
const SESSION_KEY = 'tkmind_session';
const { createUuid, SESSION_COOKIE_NAME } = require('./config');
const { clearLegalConsent } = require('./legal-consent');
let apiBaseUrl = '';
let cookie = '';
@@ -225,6 +226,7 @@ async function logout() {
await portalRequest('/auth/logout', { method: 'POST' });
} finally {
clearSession();
clearLegalConsent();
}
}
+44
View File
@@ -0,0 +1,44 @@
const CONSENT_KEY = 'tkmind_legal_consent';
const CONSENT_VERSION = '1.0';
function getStoredConsent() {
try {
const stored = wx.getStorageSync(CONSENT_KEY);
if (stored && typeof stored === 'object') {
return stored;
}
} catch {
// Ignore storage read errors.
}
return null;
}
function hasAcceptedLegalConsent() {
const stored = getStoredConsent();
return stored?.accepted === true && stored?.version === CONSENT_VERSION;
}
function saveLegalConsent() {
wx.setStorageSync(CONSENT_KEY, {
accepted: true,
version: CONSENT_VERSION,
acceptedAt: new Date().toISOString(),
});
}
function clearLegalConsent() {
wx.removeStorageSync(CONSENT_KEY);
}
function shouldSkipLoginConsent() {
return hasAcceptedLegalConsent();
}
module.exports = {
CONSENT_VERSION,
getStoredConsent,
hasAcceptedLegalConsent,
saveLegalConsent,
clearLegalConsent,
shouldSkipLoginConsent,
};
+51
View File
@@ -0,0 +1,51 @@
function canUsePrivacyApis() {
return typeof wx.getPrivacySetting === 'function';
}
function getPrivacySetting() {
return new Promise((resolve) => {
if (!canUsePrivacyApis()) {
resolve({ needAuthorization: false, privacyContractName: '' });
return;
}
wx.getPrivacySetting({
success(res) {
resolve({
needAuthorization: Boolean(res?.needAuthorization),
privacyContractName: String(res?.privacyContractName || ''),
});
},
fail() {
resolve({ needAuthorization: false, privacyContractName: '' });
},
});
});
}
function openPrivacyContract() {
if (typeof wx.openPrivacyContract !== 'function') {
wx.showToast({ title: '请升级微信版本后查看', icon: 'none' });
return;
}
wx.openPrivacyContract({
fail() {
wx.showToast({ title: '暂时无法打开隐私指引', icon: 'none' });
},
});
}
function registerPrivacyAuthorizationListener(handler) {
if (typeof wx.onNeedPrivacyAuthorization !== 'function') {
return;
}
wx.onNeedPrivacyAuthorization((resolve) => {
handler(resolve);
});
}
module.exports = {
canUsePrivacyApis,
getPrivacySetting,
openPrivacyContract,
registerPrivacyAuthorizationListener,
};
+1 -1
View File
@@ -64,7 +64,7 @@
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
"verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs",
"verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
"verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs",
"verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs",
"verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs",
@@ -14,7 +14,7 @@
{
"action": "chat",
"label": "问卷增加饮食偏好(每日几顿)",
"message": "问卷页面再增加一个饮食偏好,比如每日几顿这种",
"message": "请给问卷页面新增 q4_diet_meals(每日几顿)饮食字段,并同步更新 children-hobby-admin.html 后台列表来展示该字段;问卷、后台、Page Data 策略三处必须一致。",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
+23
View File
@@ -605,6 +605,29 @@ CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings (
CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_asset_gateway_config (
config_key VARCHAR(32) PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs (
id CHAR(36) PRIMARY KEY,
plugin_id VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
provider VARCHAR(64) NULL,
llm_provider_key_id CHAR(36) NULL,
llm_model VARCHAR(128) NULL,
updated_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uq_h5_asset_plugin (plugin_id),
KEY idx_h5_asset_plugin_llm_provider (llm_provider_key_id),
CONSTRAINT fk_h5_asset_plugin_llm_provider FOREIGN KEY (llm_provider_key_id)
REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_payment_orders (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
+35
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
@@ -16,6 +17,7 @@ const skipBuild = process.argv.includes('--skip-build');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
const linuxArm64ResvgPackage = '@resvg/resvg-js-linux-arm64-gnu';
const externalPackages = [
'@img/sharp-darwin-arm64',
@@ -195,6 +197,39 @@ async function copyNodeModules() {
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir);
await installLinuxArm64ResvgBinary();
}
// The runtime artifact is assembled on macOS but runs in a Linux ARM64 Colima VM.
// pnpm therefore copies the macOS optional resvg binary only. Keep the target
// native package alongside the copied node_modules so the dynamic require in
// @resvg/resvg-js resolves in production.
async function installLinuxArm64ResvgBinary() {
const resvgPackageJson = JSON.parse(
await fs.readFile(path.join(nodeModulesDir, '@resvg', 'resvg-js', 'package.json'), 'utf8'),
);
const version = resvgPackageJson.optionalDependencies?.[linuxArm64ResvgPackage];
if (!version) {
throw new Error(`无法确定 ${linuxArm64ResvgPackage} 的版本`);
}
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-resvg-linux-arm64-'));
const targetDir = path.join(runtimeRoot, 'node_modules', '@resvg', 'resvg-js-linux-arm64-gnu');
try {
console.log(`==> 补齐 Linux ARM64 resvg 原生依赖 (${version})`);
await run('npm', ['pack', `${linuxArm64ResvgPackage}@${version}`, '--pack-destination', stagingDir]);
const archive = (await fs.readdir(stagingDir)).find((name) => name.endsWith('.tgz'));
if (!archive) throw new Error(`未生成 ${linuxArm64ResvgPackage} 安装包`);
await remove(targetDir);
await fs.mkdir(targetDir, { recursive: true });
await run('tar', ['-xzf', path.join(stagingDir, archive), '--strip-components=1', '-C', targetDir]);
const files = await fs.readdir(targetDir);
if (!files.some((name) => name.endsWith('.node'))) {
throw new Error(`${linuxArm64ResvgPackage} 缺少原生 .node 文件`);
}
} finally {
await remove(stagingDir);
}
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) {
+19
View File
@@ -54,6 +54,17 @@ function hasCoverImageHint(html) {
return true;
}
function missingLocalCoverPath(htmlPath, coverMeta) {
const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim();
if (!cover || /^https?:\/\//i.test(cover) || cover.startsWith('data:')) return null;
const clean = cover.split('?')[0].split('#')[0].replace(/^\.\//, '');
if (!clean || clean.startsWith('/') || clean.includes('\\')) return null;
const base = path.dirname(htmlPath);
const target = path.resolve(base, clean);
if (target !== base && !target.startsWith(`${base}${path.sep}`)) return clean;
return fs.existsSync(target) && fs.statSync(target).isFile() ? null : clean;
}
function auditCoverHtml(htmlPath, html) {
const issues = [];
if (!hasMindspaceCoverMeta(html)) {
@@ -76,6 +87,14 @@ function auditCoverHtml(htmlPath, html) {
if (!String(coverMeta.accent ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_accent', message: 'mindspace-cover 缺少 accent' });
}
const missingCover = missingLocalCoverPath(htmlPath, coverMeta);
if (missingCover) {
issues.push({
level: 'error',
code: 'missing_cover_file',
message: `mindspace-cover 引用的本地文件不存在:${missingCover}`,
});
}
}
if (!hasCoverImageHint(html)) {
issues.push({
+36 -11
View File
@@ -31,12 +31,22 @@ const BASE_URL = `http://127.0.0.1:${portalPort}`;
const HTML_NAME = 'fruit-theme-test.html';
const USERNAME = 'john';
const JOHN_PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj';
const publishDir = path.join(root, 'MindSpace', USERNAME);
const htmlPath = path.join(publishDir, HTML_NAME);
const thumbRel = workspaceThumbnailRelativePath(HTML_NAME);
const publicPageUrl = buildPublicUrl(BASE_URL, USERNAME, HTML_NAME);
const publicThumbUrl = buildPublicUrl(BASE_URL, USERNAME, thumbRel);
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
const thumbPngRel = thumbRel.replace(/\.svg$/i, '.png');
let publishDir = null;
let htmlPath = null;
let publicPageUrl = null;
let publicThumbUrl = null;
const fixtureHtml = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="mindspace-cover" content="夏日鲜果指南,美食与水果主题">
<title>夏日鲜果指南</title>
</head>
<body><main><h1>夏日鲜果指南</h1><p></p></main></body>
</html>`;
let createdFixture = false;
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: root,
@@ -111,7 +121,7 @@ async function ensureJohnPassword() {
});
assert.equal(response.status, 200, 'john HTTP 登录应成功');
await pool.end();
return cookieHeader(setCookie);
return { cookie: cookieHeader(setCookie), userId: login.user.id };
}
async function main() {
@@ -119,6 +129,20 @@ async function main() {
await waitForPortal();
console.log(`BASE_URL: ${BASE_URL}`);
const { cookie, userId } = await ensureJohnPassword();
console.log('✓ john 本地登录成功');
publishDir = path.join(root, 'MindSpace', userId, 'public');
htmlPath = path.join(publishDir, HTML_NAME);
publicPageUrl = buildPublicUrl(BASE_URL, userId, `public/${HTML_NAME}`);
publicThumbUrl = buildPublicUrl(BASE_URL, userId, `public/${thumbPngRel}`);
try {
await fs.access(htmlPath);
} catch {
await fs.mkdir(publishDir, { recursive: true });
await fs.writeFile(htmlPath, fixtureHtml, 'utf8');
createdFixture = true;
}
const html = await fs.readFile(htmlPath, 'utf8');
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
@@ -135,17 +159,14 @@ async function main() {
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
const thumbCheck = await request(`/MindSpace/${USERNAME}/${thumbRel}`);
const thumbCheck = await request(`/MindSpace/${userId}/public/${thumbPngRel}`);
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
assert.match(String(thumbCheck.payload), /width="540" height="720"/, '线上预览图为 3:4');
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
assert.match(agentReplyLink, /^\[.+\]\(https?:\/\/.+\)$/);
console.log(`✓ Agent 应回复的可点击链接:\n ${agentReplyLink}`);
const cookie = await ensureJohnPassword();
console.log('✓ john 本地登录成功');
const { response: spaceRes, payload: spacePayload } = await request('/api/mindspace/v1/space', {
cookie,
});
@@ -182,4 +203,8 @@ try {
await main();
} finally {
portal.kill('SIGTERM');
if (createdFixture && htmlPath && publishDir) {
await fs.rm(htmlPath, { force: true });
await fs.rm(path.join(publishDir, thumbRel), { force: true });
}
}
+8 -4
View File
@@ -333,6 +333,7 @@ try {
const finalJob = await pollJob(owner.cookie, jobId);
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
assert.ok(finalJob.sessionId, '任务完成后应关联 Agent 会话');
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
@@ -348,11 +349,14 @@ try {
assert.equal(draftPages.response.status, 200, JSON.stringify(draftPages.body));
assert.equal(draftPages.body.data.length, 1);
const [usageRows] = await pool.query(
`SELECT COUNT(*) AS count FROM h5_usage_records WHERE user_id = ?`,
[owner.id],
const [billingRows] = await pool.query(
`SELECT last_input_tokens, last_output_tokens
FROM h5_session_billing_state
WHERE agent_session_id = ? AND user_id = ?`,
[finalJob.sessionId, owner.id],
);
assert.equal(Number(usageRows[0]?.count ?? 0), 1, 'Agent 运行后应记录一次扣费');
assert.equal(Number(billingRows[0]?.last_input_tokens ?? 0), 12, 'Agent 运行后应记录输入 Token');
assert.equal(Number(billingRows[0]?.last_output_tokens ?? 0), 20, 'Agent 运行后应记录输出 Token');
console.log('MindSpace agent jobs E2E passed');
} finally {
+32 -1
View File
@@ -30,6 +30,7 @@ const portal = spawn(process.execPath, ['server.mjs'], {
env: {
...process.env,
H5_PORT: String(portalPort),
H5_PUBLIC_BASE_URL: baseUrl,
TKMIND_API_TARGET: 'http://127.0.0.1:9',
MINDSPACE_STORAGE_ROOT: storageRoot,
MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1',
@@ -59,7 +60,8 @@ async function waitForPortal() {
}
async function request(pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, options);
const url = /^https?:\/\//i.test(pathname) ? pathname : `${baseUrl}${pathname}`;
const response = await fetch(url, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
@@ -91,9 +93,35 @@ function authHeaders(cookie) {
}
const pool = createDbPool();
let originalPublicPageLimit = null;
async function setPublicPageLimitForTest() {
const [rows] = await pool.query(
"SELECT value FROM mindspace_config WHERE `key` = 'public_page_limit' LIMIT 1",
);
originalPublicPageLimit = rows[0]?.value ?? null;
await pool.query(
`INSERT INTO mindspace_config (\`key\`, value, description, updated_at)
VALUES ('public_page_limit', '1', '公开页面数量上限', ?)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)`,
[Date.now()],
);
}
async function restorePublicPageLimit() {
if (originalPublicPageLimit == null) {
await pool.query("DELETE FROM mindspace_config WHERE `key` = 'public_page_limit'");
return;
}
await pool.query(
"UPDATE mindspace_config SET value = ?, updated_at = ? WHERE `key` = 'public_page_limit'",
[originalPublicPageLimit, Date.now()],
);
}
try {
await waitForPortal();
await setPublicPageLimitForTest();
const owner = await registerAndLogin(users[0]);
const other = await registerAndLogin(users[1]);
@@ -243,6 +271,7 @@ try {
let currentPublicationId = republished.body.data.id;
const offlineCurrentPublication = async () => {
if (!currentPublicationId) return;
const response = await request(
`/api/mindspace/v1/publications/${currentPublicationId}/offline`,
{
@@ -318,6 +347,7 @@ try {
]);
const afterExpiry = await request(publicUrl);
assert.equal(afterExpiry.response.status, 404);
currentPublicationId = null;
const finalPublication = await publishMode('public');
const finalView = await request(publicUrl, {
@@ -410,6 +440,7 @@ try {
console.log('MindSpace publication API E2E passed');
} finally {
await restorePublicPageLimit();
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
users[0].username,
users[1].username,
+8 -6
View File
@@ -6,6 +6,7 @@ import { USER_COOKIE } from '../user-auth.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const scenarioH5Root = path.resolve(process.env.MEMIND_SCENARIO_H5_ROOT ?? repoRoot);
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -246,7 +247,7 @@ async function listPublicHtmlFiles(publishDir) {
}
export async function snapshotPublicHtml(publishKey) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
return listPublicHtmlFiles(publishDir);
}
@@ -304,7 +305,7 @@ export async function verifyPageAccess({
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
if (!pageUrl && publishKey) {
const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
const htmlAfter = await listPublicHtmlFiles(path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey));
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
const fresh = htmlAfter
.filter((item) => !beforeSet.has(item.fullPath))
@@ -359,7 +360,7 @@ export async function verifySurveyDelivery({
expect = {},
reporter,
}) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const policyDir = path.join(publishDir, '.mindspace', 'page-data-policies');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
@@ -469,7 +470,7 @@ async function readJsonIfExists(filePath) {
}
async function findPublicSurveyPolicy(publishKey, datasetName) {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
const policyDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
let entries = [];
try {
entries = await fs.readdir(policyDir);
@@ -493,7 +494,7 @@ export async function verifyChildrenHobbyDietSurvey({
testInsert = true,
spec = CHILDREN_HOBBY_DIET_SURVEY,
} = {}) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
let ok = true;
@@ -524,7 +525,8 @@ export async function verifyChildrenHobbyDietSurvey({
} else {
reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`);
}
if (!adminHtml.includes('每日饮食') || !adminHtml.includes(spec.dietField)) {
const adminHasDietLabel = spec.dietKeywords.some((keyword) => adminHtml.includes(keyword));
if (!adminHasDietLabel || !adminHtml.includes(spec.dietField)) {
reporter.fail('后台饮食列', `未展示 ${spec.dietField}`);
ok = false;
} else {
+32
View File
@@ -135,6 +135,7 @@ import {
syncPublicHtmlAfterFinish,
} 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 { 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';
@@ -713,6 +714,13 @@ async function bootstrapUserAuth() {
config: WECHAT_MP_CONFIG,
userAuth,
sessionAccess,
pageDataFinishGuard: authPool
? {
pool: authPool,
h5Root: __dirname,
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
}
: null,
apiFetch: tkmindProxy.apiFetch,
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
@@ -5047,6 +5055,30 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
syncResult,
tkmindProxy,
});
const lastUserMessage = [...(Array.isArray(messages) ? messages : [])]
.reverse()
.find((message) => message?.role === 'user');
const lastUserText =
typeof lastUserMessage?.content === 'string'
? lastUserMessage.content
: Array.isArray(lastUserMessage?.content)
? lastUserMessage.content
.filter((item) => item?.type === 'text')
.map((item) => String(item.text ?? '').trim())
.filter(Boolean)
.join('\n')
: '';
await maybeRepairPageDataAfterFinish({
sessionId: sid,
userId: uid,
publishDir,
messages,
pool: authPool,
h5Root: __dirname,
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
tkmindProxy,
userText: lastUserText,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
+1
View File
@@ -274,6 +274,7 @@ CREATE TABLE IF NOT EXISTS survey_responses (
10. **禁止**只 `CREATE TABLE` 而不 `private_data_register_dataset`
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
13. **禁止**在 Page Data 页面中使用 `localStorage` / `sessionStorage` 存提交记录或做 API fallback
## 交付前自检
+72 -2
View File
@@ -23,7 +23,10 @@ import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
import { filterMemoriesByQuery, resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs';
import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
import {
collectPageDataDeliveryArtifacts,
rewritePageDataDeliveryLinks,
} from './mindspace-page-data-finish-guard.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
buildCurrentTurnImageScopeNote,
@@ -453,6 +456,9 @@ const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
const PUBLIC_HTML_MARKDOWN_LINK_PATTERN =
/\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi;
const MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+)\)/g;
const PLAIN_HTML_URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\.html/gi;
const INLINE_PUBLIC_HTML_PATH_PATTERN = /`((?:\.\/)?public\/[^`\s<>"')\]]+\.html)`/gi;
const USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/;
const IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g;
const TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/;
@@ -524,6 +530,57 @@ function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser
};
}
function isCurrentUserPublicOwner(owner, currentUser) {
const normalizedOwner = String(owner ?? '').trim().toLowerCase();
const normalizedUserId = String(currentUser?.id ?? '').trim().toLowerCase();
const normalizedUsername = String(currentUser?.username ?? '').trim().toLowerCase();
return Boolean(normalizedOwner && (
normalizedOwner === normalizedUserId ||
normalizedOwner === normalizedUsername
));
}
function sanitizeMissingMindSpacePublicHtmlUrl(url, currentUser) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
const parts = parsed.pathname
.split('/')
.filter(Boolean)
.map((part) => decodePathSegment(part));
if (parts[0]?.toLowerCase() === 'mindspace') return null;
const owner = parts[0];
if (!isCurrentUserPublicOwner(owner, currentUser)) return null;
const rawRelativePath = parts.slice(1).join('/');
if (!rawRelativePath.toLowerCase().endsWith('.html')) return null;
const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser);
return result.ok ? result.url : result.notice;
}
function sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser) {
const owner = currentUser?.id || currentUser?.username;
if (!owner) return null;
const normalizedRelativePath = normalizeStaticHtmlRelativePath(rawRelativePath);
if (!normalizedRelativePath.toLowerCase().endsWith('.html')) return null;
if (!publicHtmlExistsForUser(owner, normalizedRelativePath, currentUser)) return null;
return {
label: path.posix.basename(normalizedRelativePath),
url: buildPublicUrl(resolvePublicBaseUrl(), owner, normalizedRelativePath),
};
}
function rewriteOwnPublicationRouteLinks(text, currentUser) {
const owner = String(currentUser?.id ?? currentUser?.username ?? '').trim();
if (!owner) return String(text ?? '');
const publishDir = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner);
const artifacts = collectPageDataDeliveryArtifacts(publishDir);
if (!artifacts.length) return String(text ?? '');
return rewritePageDataDeliveryLinks(text, artifacts);
}
export function sanitizePublicHtmlLinksInText(text, currentUser) {
let next = String(text ?? '').replace(
PUBLIC_HTML_MARKDOWN_LINK_PATTERN,
@@ -533,10 +590,23 @@ export function sanitizePublicHtmlLinksInText(text, currentUser) {
return label ? `[${label}](${result.url})` : result.url;
},
);
return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => {
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => {
const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser);
return result.ok ? result.url : result.notice;
});
next = next.replace(MARKDOWN_LINK_PATTERN, (match, label, url) => {
const sanitizedUrl = sanitizeMissingMindSpacePublicHtmlUrl(url, currentUser);
if (!sanitizedUrl) return match;
return label ? `[${label}](${sanitizedUrl})` : sanitizedUrl;
});
next = next.replace(PLAIN_HTML_URL_PATTERN, (match) => (
sanitizeMissingMindSpacePublicHtmlUrl(match, currentUser) ?? match
));
next = next.replace(INLINE_PUBLIC_HTML_PATH_PATTERN, (match, rawRelativePath) => {
const result = sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser);
return result ? `[${result.label}](${result.url})` : match;
});
return rewriteOwnPublicationRouteLinks(next, currentUser);
}
export function sanitizeUserVisibleMessageText(text, currentUser) {
+83
View File
@@ -216,6 +216,89 @@ test('sanitizePublicHtmlLinksInText canonicalizes wrong MindSpace public hosts',
}
});
test('sanitizePublicHtmlLinksInText canonicalizes public html links missing MindSpace segment', () => {
const owner = `test-user-${Date.now()}-missing-mindspace`;
const previousBase = process.env.H5_PUBLIC_BASE_URL;
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'kuanting-plan.html');
try {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(htmlPath, '<!doctype html><title>宽庭</title>');
const text =
`[宽庭方案](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`;
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
assert.doesNotMatch(next, /mindspace\.tkmind\.com/);
assert.match(
next,
new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/kuanting-plan\\.html`),
);
assert.doesNotMatch(next, /页面生成未完成/);
} finally {
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
else process.env.H5_PUBLIC_BASE_URL = previousBase;
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
}
});
test('sanitizePublicHtmlLinksInText leaves other users missing-MindSpace links unchanged', () => {
const owner = `test-user-${Date.now()}-other-missing-mindspace`;
const text = `[外部页面](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`;
const next = sanitizePublicHtmlLinksInText(text, { id: `current-${owner}`, username: 'john' });
assert.equal(next, text);
});
test('sanitizePublicHtmlLinksInText links existing inline public html paths', () => {
const owner = `test-user-${Date.now()}-inline-public-path`;
const previousBase = process.env.H5_PUBLIC_BASE_URL;
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'thailand-travel-guide.html');
try {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(htmlPath, '<!doctype html><title>Thailand</title>');
const text = '攻略页面已成功创建!文件位于 **`public/thailand-travel-guide.html`**(约 37KB)。';
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john2' });
assert.doesNotMatch(next, /`public\/thailand-travel-guide\.html`/);
assert.match(
next,
new RegExp(`\\[thailand-travel-guide\\.html\\]\\(https://m\\.tkmind\\.cn/MindSpace/${owner}/public/thailand-travel-guide\\.html\\)`),
);
} finally {
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
else process.env.H5_PUBLIC_BASE_URL = previousBase;
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
}
});
test('sanitizePublicHtmlLinksInText rewrites own publication route links to MindSpace workspace urls', () => {
const owner = `test-user-${Date.now()}-pub-route`;
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
const previousBase = process.env.H5_PUBLIC_BASE_URL;
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
try {
fs.mkdirSync(publicRoot, { recursive: true });
fs.writeFileSync(
path.join(publicRoot, 'zhiqu-survey.html'),
'<!doctype html><title>问卷</title><script src="/assets/page-data-client.js"></script>',
);
fs.writeFileSync(
path.join(publicRoot, 'zhiqu-survey-admin.html'),
'<!doctype html><title>后台</title><script src="/assets/page-data-client.js"></script>',
);
const text = [
'问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
'后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
].join('\n');
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
assert.doesNotMatch(next, /\/u\/john\/pages\//);
assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey\\.html`));
assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey-admin\\.html`));
} finally {
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
else process.env.H5_PUBLIC_BASE_URL = previousBase;
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
}
});
test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => {
const owner = `test-user-${Date.now()}-conversation`;
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
+107
View File
@@ -29,6 +29,13 @@ import {
} from './wechat/prompts/page-generate.mjs';
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
import {
buildPageDataCollectFailureText,
buildPageDataDeliveryArtifactsFromBindResult,
maybeAutoBindPageDataHtmlPages,
resolvePageDataCollectOutcome,
rewritePageDataDeliveryLinks,
} from './mindspace-page-data-finish-guard.mjs';
export { buildWechatAgentPrompt };
@@ -1039,6 +1046,61 @@ function markWechatUserNotified(err) {
return err;
}
async function enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId,
pageDataFinishGuard,
publicBaseUrl,
requestStartedAt = 0,
notifyFailure,
}) {
let outcome = resolvePageDataCollectOutcome({
reply,
intent,
publishDir: workingDir,
requestStartedAt,
});
let autoBind = null;
if (outcome.action === 'skip') return outcome;
if (pageDataFinishGuard?.pool) {
autoBind = await maybeAutoBindPageDataHtmlPages({
pool: pageDataFinishGuard.pool,
userId,
publishDir: workingDir,
h5Root: pageDataFinishGuard.h5Root,
storageRoot: pageDataFinishGuard.storageRoot,
});
outcome = resolvePageDataCollectOutcome({
reply,
intent,
publishDir: workingDir,
requestStartedAt,
});
}
if (outcome.action === 'retry') {
throw new Error('stale_session_poisoned_completion');
}
if (outcome.action === 'fail') {
const text = outcome.failureText ?? buildPageDataCollectFailureText();
if (typeof notifyFailure === 'function') {
await notifyFailure(text);
}
throw markWechatUserNotified(new Error(text));
}
const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, {
publicBaseUrl,
});
if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') {
reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts);
}
return { ...outcome, deliveryArtifacts, autoBind };
}
function wasWechatUserNotified(err) {
return Boolean(err && typeof err === 'object' && err.wechatUserNotified);
}
@@ -1299,6 +1361,7 @@ export function createWechatMpService({
llmProviderService = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
pageDataFinishGuard = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
logger = console,
@@ -1926,6 +1989,28 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion');
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId: user.userId,
pageDataFinishGuard,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
},
});
if (pageDataOutcome?.deliveryArtifacts?.length) {
publishArtifacts = uniqueArtifactsByUrl([
...pageDataOutcome.deliveryArtifacts,
...publishArtifacts,
]);
}
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
@@ -2039,6 +2124,28 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error(buildHtmlPublishFailureText());
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
workingDir,
userId: user.userId,
pageDataFinishGuard,
publicBaseUrl: config.publicBaseUrl,
requestStartedAt: retryStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data retry failure notice failed:', sendErr);
}
},
});
if (pageDataOutcome?.deliveryArtifacts?.length) {
publishArtifacts = uniqueArtifactsByUrl([
...pageDataOutcome.deliveryArtifacts,
...publishArtifacts,
]);
}
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
+13 -1
View File
@@ -1,4 +1,4 @@
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mjs';
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
@@ -31,6 +31,16 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
'',
].join('\n')
: '';
const pageDataCollectHint = isPageDataIntent(agentText)
? [
'【Page Data 问卷/数据收集要求】这条消息需要可提交、可持久化、可后台查看的数据页面。',
'开始前必须先调用 `load_skill` → `page-data-collect`,并按技能完成:建表 → register dataset → 写 HTML(必须引入 /assets/page-data-client.js)→ private_data_bind_workspace_page。',
'禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express 或独立端口 API。',
'交付链接必须使用 bind 返回的 workspaceUrl/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 发布路由链接。',
'问卷页与后台页必须分别 bindpublic insert + password read);未完成 bind 前不要发送链接或说「已发布/可提交」。',
'',
].join('\n')
: '';
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
const scheduleAssistantHint = shouldUseScheduleAssistant(agentText)
@@ -48,6 +58,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
if (msgType === 'voice') {
return [
docxDownloadHint,
pageDataCollectHint,
currentTimeHint,
scheduleAssistantHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
@@ -104,6 +115,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
const content = String(agentText).trim();
const lines = [currentTimeHint];
if (docxDownloadHint) lines.push(docxDownloadHint);
if (pageDataCollectHint) lines.push(pageDataCollectHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
lines.push(