Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5704cb11b0 | |||
| b86ce98d6f | |||
| 294e331a03 | |||
| ee3e405fc7 | |||
| 048f25f580 | |||
| 8928827291 | |||
| 4e36b77237 | |||
| dc7b440bfb | |||
| fdc2712084 | |||
| 680d28ac4b | |||
| f4d9897072 | |||
| 7dd6b060f7 | |||
| a3f669d4e7 | |||
| 880cd59ca5 | |||
| adc13a167e | |||
| 31742f914e |
+7
-1
@@ -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,
|
||||
|
||||
@@ -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 配置服务未启用' });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
|
||||
|
||||
@@ -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 是保护用户不收到假链接,真正问题在路由或落盘。
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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() {} },
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "TKMind 聊天"
|
||||
"navigationBarTitleText": "TKMind 聊天",
|
||||
"usingComponents": {
|
||||
"privacy-popup": "/components/privacy-popup/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "隐私与协议"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Page({});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "隐私政策"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Page({});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "用户服务协议"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../privacy/index.wxss';
|
||||
@@ -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 || '登录失败,请重试' });
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录 TKMind"
|
||||
"navigationBarTitleText": "登录 TKMind",
|
||||
"usingComponents": {
|
||||
"privacy-popup": "/components/privacy-popup/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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/... 发布路由链接。',
|
||||
'问卷页与后台页必须分别 bind(public 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(
|
||||
|
||||
Reference in New Issue
Block a user