merge: feature/page-data-dev-aider into main

Bring in Page Data Aider dev loop, adm code-run policy UI, and customer order demo.
Keep system disclosure policy and agent code-run admin wiring side by side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-24 14:42:25 +08:00
39 changed files with 3295 additions and 47 deletions
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
/**
* CI/staging smoke for Page Data dev loop (dry-run only, no Aider spawn).
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const script = path.join(repoRoot, 'scripts/page-data-aider-dev-loop.mjs');
const result = spawnSync(
process.execPath,
[script, '--dry-run', '--skip-initial-verify', '--max-attempts', '1', '--escalate-after', '1'],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* 将当前 env 中的 code-run 灰度配置写入 h5_agent_code_run_config(一次性迁移)。
*
* Usage:
* node scripts/migrate-agent-code-run-config-from-env.mjs
* node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
* node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by <admin-user-id>
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import {
agentCodeRunAdminConfigInternals,
createAgentCodeRunAdminConfigService,
} from '../agent-code-run-admin-config.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function parseArgs(argv) {
const options = { dryRun: false, updatedBy: null, force: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--force') options.force = true;
else if (arg === '--updated-by' && argv[i + 1]) options.updatedBy = argv[++i];
else if (arg === '-h' || arg === '--help') {
console.log('Usage: node scripts/migrate-agent-code-run-config-from-env.mjs [--dry-run] [--force] [--updated-by <uuid>]');
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return options;
}
async function main() {
loadH5Environment(import.meta.dirname);
const options = parseArgs(process.argv.slice(2));
if (String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env') {
throw new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不应写入 admin-db');
}
const pool = createDbPool();
const service = createAgentCodeRunAdminConfigService(pool, { env: process.env });
const current = await service.getAdminConfig();
const envConfig = agentCodeRunAdminConfigInternals.buildConfigFromEnv(process.env);
console.log('==> migrate agent code run config from env');
console.log(` current source: ${current.source}`);
console.log(` env enabled: ${envConfig.codeRun.enabled}`);
console.log(` env clientEnabled: ${envConfig.codeRun.clientEnabled}`);
console.log(` env pageDataDev.autodetect: ${envConfig.pageDataDev.autodetect}`);
if (current.source === 'admin-db' && !options.force) {
console.log('\nadmin-db 已有配置;加 --force 才会覆盖');
await pool.end();
process.exit(0);
}
if (options.dryRun) {
console.log('\n(dry-run) 将写入:');
console.log(JSON.stringify(envConfig, null, 2));
await pool.end();
process.exit(0);
}
const saved = await service.updateAdminConfig(
{ config: envConfig, meta: { notes: 'migrated from env via migrate-agent-code-run-config-from-env.mjs' } },
{ updatedBy: options.updatedBy },
);
console.log('\n✔ 已写入 admin-db');
console.log(JSON.stringify(saved, null, 2));
await pool.end();
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env node
/**
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verifyPhase 2)。
*
* Usage:
* node scripts/page-data-aider-dev-loop.mjs
* node scripts/page-data-aider-dev-loop.mjs --preset children-hobby-diet-survey
* node scripts/page-data-aider-dev-loop.mjs --preset page-data-platform --max-attempts 2
* node scripts/page-data-aider-dev-loop.mjs --dry-run
* node scripts/page-data-aider-dev-loop.mjs --verify-cmd "node scripts/verify-children-hobby-diet-survey.mjs --no-insert"
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { createLlmProviderService } from '../llm-providers.mjs';
import { createToolGateway } from '../tool-gateway.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
import { CHILDREN_HOBBY_DIET_SURVEY } from './scenario-test-lib.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
export const PAGE_DATA_DEV_COMPLEX_TASK_TYPE = 'page_data_dev_complex';
export const VERIFY_PRESETS = Object.freeze({
'children-hobby-diet-survey': {
label: 'john4 儿童爱好问卷饮食偏好',
scenario: 'workspace',
defaultUserId: CHILDREN_HOBBY_DIET_SURVEY.userId,
buildVerifyArgs: (options) => [
'scripts/verify-children-hobby-diet-survey.mjs',
'--user-id',
options.userId,
...(options.testInsert === false ? ['--no-insert'] : []),
...(options.checkRuns === false ? ['--no-runs'] : []),
...(options.port ? ['--port', String(options.port)] : []),
],
defaultTargetFiles: () => [
`public/${CHILDREN_HOBBY_DIET_SURVEY.surveyHtml}`,
`public/${CHILDREN_HOBBY_DIET_SURVEY.adminHtml}`,
],
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
},
'page-data-platform': {
label: 'Memind Page Data 平台单测',
scenario: 'platform',
defaultUserId: null,
buildVerifyArgs: () => ['--test', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs'],
defaultTargetFiles: () => [
'page-data-public-service.mjs',
'page-data-service.mjs',
'public/assets/page-data-client.js',
],
verifyCommandLabel: 'npm run verify:page-data',
},
});
function printHelp() {
console.log([
'Usage:',
' node scripts/page-data-aider-dev-loop.mjs [options]',
'',
'Options:',
' --preset <name> children-hobby-diet-survey (default) | page-data-platform',
' --verify-cmd <shell> 自定义 verify 命令(覆盖 preset',
' --user-id <uuid> workspace 用户 IDworkspace 场景)',
' --cwd <dir> 覆盖 Aider 工作目录',
' --target-file <path> 可多次指定;未指定时用 preset 默认',
' --max-attempts <n> 最大修复轮次(默认 3)',
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 20=禁用)',
' --executor <aider|openhands> 强制全程使用指定执行器',
' --no-insert 传给 preset verify',
' --no-runs 传给 preset verify(跳过 orphan run 检查)',
' --port <n> Portal 端口(默认 H5_PORT 或 8081',
' --dry-run 只打印 instruction,不执行 Aider',
' --skip-initial-verify 跳过首轮 verify,直接跑 Aider(调试用)',
' -h, --help',
'',
'Requires: DATABASE_URLAider launch plan)、MEMIND_TOOL_GATEWAY_ENABLED=1(或 --dry-run',
].join('\n'));
}
export function parseDevLoopArgs(argv, env = process.env) {
const options = {
preset: 'children-hobby-diet-survey',
verifyCmd: null,
userId: null,
cwd: null,
targetFiles: [],
maxAttempts: 3,
escalateAfter: 2,
executor: 'aider',
testInsert: true,
checkRuns: true,
port: Number(env.H5_PORT ?? 8081),
dryRun: false,
skipInitialVerify: false,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '-h' || arg === '--help') options.help = true;
else if (arg === '--preset' && argv[i + 1]) options.preset = argv[++i];
else if (arg === '--verify-cmd' && argv[i + 1]) options.verifyCmd = argv[++i];
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
else if (arg === '--cwd' && argv[i + 1]) options.cwd = path.resolve(argv[++i]);
else if (arg === '--target-file' && argv[i + 1]) options.targetFiles.push(argv[++i]);
else if (arg === '--max-attempts' && argv[i + 1]) options.maxAttempts = Math.max(1, Number(argv[++i]) || 3);
else if (arg === '--escalate-after' && argv[i + 1]) {
options.escalateAfter = Math.max(0, Number(argv[++i]) || 0);
}
else if (arg === '--executor' && argv[i + 1]) options.executor = String(argv[++i]).trim().toLowerCase();
else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
else if (arg === '--no-insert') options.testInsert = false;
else if (arg === '--no-runs') options.checkRuns = false;
else if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--skip-initial-verify') options.skipInitialVerify = true;
else throw new Error(`未知参数: ${arg}`);
}
return options;
}
export function resolveVerifyPreset(presetName) {
const preset = VERIFY_PRESETS[presetName];
if (!preset) {
throw new Error(`未知 preset: ${presetName}(可选: ${Object.keys(VERIFY_PRESETS).join(', ')}`);
}
return preset;
}
export function resolveWorkspaceCwd({ repoRoot: root, userId, cwdOverride, scenario }) {
if (cwdOverride) return cwdOverride;
if (scenario === 'platform') return root;
if (!userId) {
throw new Error('workspace 场景需要 --user-id 或 preset 默认 userId');
}
return path.join(root, PUBLISH_ROOT_DIR, userId);
}
export function resolveDevLoopExecutor(
attempt,
{ executor = 'aider', escalateAfter = 2 } = {},
) {
if (executor !== 'aider') {
return {
executor,
taskType: executor === 'openhands' ? PAGE_DATA_DEV_COMPLEX_TASK_TYPE : PAGE_DATA_DEV_TASK_TYPE,
escalated: false,
};
}
if (escalateAfter > 0 && attempt > escalateAfter) {
return {
executor: 'openhands',
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
escalated: true,
};
}
return {
executor: 'aider',
taskType: PAGE_DATA_DEV_TASK_TYPE,
escalated: false,
};
}
export function buildPageDataDevInstruction({
verifyOutput,
cwd,
taskType = PAGE_DATA_DEV_TASK_TYPE,
targetFiles = [],
verifyCommandLabel = 'verify script',
escalated = false,
}) {
const targets = targetFiles.length
? targetFiles.map((item) => `- ${item}`).join('\n')
: '- public/*.html\n- .mindspace/page-data-policies/*.json';
return [
'[Page Data 开发修复任务]',
escalated ? '[Escalation] 前序 Aider 修复未通过 verify,现由 OpenHands 接手。' : '',
'',
`工作目录: ${cwd}`,
`taskType: ${taskType}`,
'',
'验证失败输出(必须原样附上):',
String(verifyOutput ?? '').trim() || '(verify 未返回输出)',
'',
'目标文件(仅可改这些):',
targets,
'',
'约束:',
'- 必须使用 /assets/page-data-client.js 公开 API',
'- 禁止 localStorage / sessionStorage / IndexedDB',
'- 禁止自建 Express 或独立后端',
'- 列名必须与 dataset register 的 insert/read 白名单一致',
'- 不要修改 Memind 平台源码(workspace 场景)',
'- 不要调用 private_data_execute 或重建 dataset(除非 verify 明确报 table_not_found',
'',
'验收:',
`- 修改后应能通过: ${verifyCommandLabel}`,
'- 完成后简要说明改了哪些文件、为何能修复 verify 失败项',
].join('\n');
}
function runVerifyCommand({ command, args, cwd, env }) {
const result = spawnSync(command, args, {
cwd,
env,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
});
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
return {
ok: result.status === 0,
status: result.status,
output: output || `(exit ${result.status ?? 'unknown'}, no output)`,
commandLine: `${command} ${args.join(' ')}`.trim(),
};
}
function resolveVerifyRun(options, preset) {
if (options.verifyCmd) {
return {
label: options.verifyCmd,
...runVerifyCommand({
command: '/bin/sh',
args: ['-lc', options.verifyCmd],
cwd: repoRoot,
env: process.env,
}),
};
}
const userId = options.userId ?? preset.defaultUserId;
if (preset.scenario === 'workspace') {
options.userId = userId;
}
const verifyArgs = preset.buildVerifyArgs({
userId,
testInsert: options.testInsert,
checkRuns: options.checkRuns,
port: options.port,
});
const useNpmForPlatform = preset.scenario === 'platform'
&& verifyArgs[0] === '--test';
if (useNpmForPlatform) {
return {
label: preset.verifyCommandLabel,
...runVerifyCommand({
command: 'npm',
args: ['run', 'verify:page-data'],
cwd: repoRoot,
env: process.env,
}),
};
}
return {
label: preset.verifyCommandLabel,
...runVerifyCommand({
command: process.execPath,
args: verifyArgs,
cwd: repoRoot,
env: process.env,
}),
};
}
async function runCodeExecutorFix({
instruction,
cwd,
executor,
taskType,
dryRun,
llmProviderService,
}) {
const previousGatewayFlag = process.env.MEMIND_TOOL_GATEWAY_ENABLED;
if (!dryRun) {
process.env.MEMIND_TOOL_GATEWAY_ENABLED = '1';
}
const gateway = createToolGateway({ llmProviderService, env: process.env });
const status = gateway.getStatus();
if (!status.enabled && !dryRun) {
throw new Error('MEMIND_TOOL_GATEWAY_ENABLED 未开启;请设 1 或使用 --dry-run');
}
const userMessage = {
role: 'user',
content: [{ type: 'text', text: instruction }],
metadata: {
memindRun: {
executor,
taskType,
},
},
};
try {
return await gateway.executeJob({
runId: `dev-loop-${Date.now()}`,
requestId: `dev-loop-${Date.now()}`,
userId: 'dev-loop',
userMessage,
taskType,
cwd,
timeoutMs: Number(process.env.MEMIND_PAGE_DATA_DEV_LOOP_TIMEOUT_MS ?? 20 * 60 * 1000),
});
} finally {
if (previousGatewayFlag == null) {
delete process.env.MEMIND_TOOL_GATEWAY_ENABLED;
} else {
process.env.MEMIND_TOOL_GATEWAY_ENABLED = previousGatewayFlag;
}
}
}
async function main() {
loadH5Environment(import.meta.dirname);
const options = parseDevLoopArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
const preset = resolveVerifyPreset(options.preset);
const userId = options.userId ?? preset.defaultUserId;
const workspaceCwd = resolveWorkspaceCwd({
repoRoot,
userId,
cwdOverride: options.cwd,
scenario: preset.scenario,
});
if (preset.scenario === 'workspace' && !fs.existsSync(workspaceCwd)) {
throw new Error(`workspace 不存在: ${workspaceCwd}`);
}
const targetFiles = options.targetFiles.length
? options.targetFiles
: preset.defaultTargetFiles();
console.log('==> Page Data Aider dev loop');
console.log(` preset: ${options.preset} (${preset.label})`);
console.log(` cwd: ${workspaceCwd}`);
console.log(` max attempts: ${options.maxAttempts}`);
console.log(` escalate after: ${options.escalateAfter} aider round(s)`);
console.log(` dry-run: ${options.dryRun ? 'yes' : 'no'}\n`);
let pool = null;
let llmProviderService = null;
if (!options.dryRun) {
pool = createDbPool();
llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
});
}
const shutdown = async () => {
if (pool) await pool.end().catch(() => {});
};
try {
let verifyResult = options.skipInitialVerify
? { ok: false, output: '(跳过首轮 verify)', label: options.verifyCmd ?? preset.verifyCommandLabel }
: null;
if (!options.skipInitialVerify) {
console.log('--- 首轮 verify ---');
verifyResult = resolveVerifyRun(options, preset);
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
if (verifyResult.ok) {
console.log('\n✔ verify 已通过,无需 Aider 修复');
await shutdown();
process.exit(0);
}
console.log(verifyResult.output.slice(-8000));
}
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
const execution = resolveDevLoopExecutor(attempt, {
executor: options.executor,
escalateAfter: options.escalateAfter,
});
const instruction = buildPageDataDevInstruction({
verifyOutput: verifyResult.output,
cwd: workspaceCwd,
targetFiles,
verifyCommandLabel: verifyResult.label,
taskType: execution.taskType,
escalated: execution.escalated,
});
console.log(`\n--- 修复第 ${attempt}/${options.maxAttempts} 轮 (${execution.executor}) ---`);
console.log(instruction.slice(0, 2000));
if (instruction.length > 2000) {
console.log(`... (${instruction.length - 2000} more chars)`);
}
if (options.dryRun) {
console.log('\n(dry-run: 未执行 Aider)');
await shutdown();
process.exit(0);
}
console.log(`\n--- 执行 ${execution.executor} ---`);
const result = await runCodeExecutorFix({
instruction,
cwd: workspaceCwd,
executor: execution.executor,
taskType: execution.taskType,
dryRun: false,
llmProviderService,
});
console.log(`${execution.executor} 完成: exit=${result.exitCode ?? 0} taskType=${execution.taskType}`);
if (result.stderr) {
console.log(result.stderr.slice(-2000));
}
console.log('\n--- 重跑 verify ---');
verifyResult = resolveVerifyRun(options, preset);
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
if (verifyResult.ok) {
console.log(`\n✔ 第 ${attempt} 轮修复后 verify 通过`);
await shutdown();
process.exit(0);
}
console.log(verifyResult.output.slice(-4000));
}
console.error(`\n${options.maxAttempts} 轮修复后 verify 仍未通过`);
await shutdown();
process.exit(1);
} catch (error) {
await shutdown();
throw error;
}
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
}
export const pageDataAiderDevLoopInternals = {
PAGE_DATA_DEV_TASK_TYPE,
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
VERIFY_PRESETS,
};
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import {
VERIFY_PRESETS,
buildPageDataDevInstruction,
parseDevLoopArgs,
resolveDevLoopExecutor,
resolveVerifyPreset,
resolveWorkspaceCwd,
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
} from './page-data-aider-dev-loop.mjs';
test('parseDevLoopArgs applies defaults and flags', () => {
const options = parseDevLoopArgs([
'--preset',
'page-data-platform',
'--max-attempts',
'2',
'--dry-run',
'--no-insert',
]);
assert.equal(options.preset, 'page-data-platform');
assert.equal(options.maxAttempts, 2);
assert.equal(options.dryRun, true);
assert.equal(options.testInsert, false);
});
test('resolveVerifyPreset rejects unknown preset', () => {
assert.throws(() => resolveVerifyPreset('missing'), /未知 preset/);
assert.equal(resolveVerifyPreset('children-hobby-diet-survey').scenario, 'workspace');
});
test('resolveWorkspaceCwd maps workspace and platform scenarios', () => {
const root = '/repo';
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: 'user-1',
cwdOverride: null,
scenario: 'workspace',
}),
path.join(root, 'MindSpace', 'user-1'),
);
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: null,
cwdOverride: '/custom',
scenario: 'workspace',
}),
'/custom',
);
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: null,
cwdOverride: null,
scenario: 'platform',
}),
root,
);
});
test('buildPageDataDevInstruction includes verify output and targets', () => {
const instruction = buildPageDataDevInstruction({
verifyOutput: '✘ 问卷提交字段: insertRow 未包含 q4_diet_meals',
cwd: '/repo/MindSpace/user-1',
targetFiles: ['public/survey.html', '.mindspace/page-data-policies/page.json'],
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
});
assert.match(instruction, /Page Data 开发修复任务/);
assert.match(instruction, /q4_diet_meals/);
assert.match(instruction, /public\/survey\.html/);
assert.match(instruction, /npm run verify:children-hobby-diet-survey/);
assert.match(instruction, /page-data-client\.js/);
});
test('resolveDevLoopExecutor escalates to openhands after N aider rounds', () => {
assert.deepEqual(resolveDevLoopExecutor(1, { escalateAfter: 2 }), {
executor: 'aider',
taskType: 'page_data_dev',
escalated: false,
});
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 2 }), {
executor: 'openhands',
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
escalated: true,
});
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 0 }), {
executor: 'aider',
taskType: 'page_data_dev',
escalated: false,
});
});
test('buildPageDataDevInstruction marks openhands escalation', () => {
const instruction = buildPageDataDevInstruction({
verifyOutput: 'failed',
cwd: '/repo/MindSpace/u1',
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
escalated: true,
verifyCommandLabel: 'npm run verify:page-data',
});
assert.match(instruction, /OpenHands 接手/);
assert.match(instruction, /page_data_dev_complex/);
});
test('children-hobby preset builds verify args with user id', () => {
const preset = VERIFY_PRESETS['children-hobby-diet-survey'];
const args = preset.buildVerifyArgs({
userId: 'abc',
testInsert: false,
checkRuns: false,
port: 9090,
});
assert.deepEqual(args, [
'scripts/verify-children-hobby-diet-survey.mjs',
'--user-id',
'abc',
'--no-insert',
'--no-runs',
'--port',
'9090',
]);
});
+3 -1
View File
@@ -386,7 +386,9 @@ export async function verifySurveyDelivery({
return false;
}
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
const surveyLike = htmlFiles.filter(
(name) => /survey|问卷|feature|order|下单|submit|form|customer/i.test(name) && !/admin|后台|manage/i.test(name),
);
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
if (surveyLike.length === 0) {
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
/**
* john4 客户下单系统(Page Data 前后台)演示搭建。
* Usage: node scripts/setup-customer-order-demo.mjs
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
import { createUserDataSpaceService } from '../user-data-space-service.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(import.meta.dirname);
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', USER_ID);
const ADMIN_PASSWORD = '88888888';
const DATASET = 'customer_orders';
const ORDER_FORM_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客户下单</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(135deg,#0f766e,#115e59); min-height: 100vh; padding: 32px 16px; }
.card { max-width: 640px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 28px; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
h1 { text-align: center; margin-bottom: 8px; color: #134e4a; }
p.sub { text-align: center; color: #64748b; margin-bottom: 24px; font-size: 14px; }
label { display: block; font-weight: 600; margin: 14px 0 6px; color: #334155; }
input, textarea { width: 100%; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; font: inherit; }
textarea { min-height: 80px; resize: vertical; }
button { width: 100%; margin-top: 20px; padding: 14px; border: 0; border-radius: 12px; background: #0f766e; color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; }
button:disabled { opacity: .6; cursor: not-allowed; }
.msg { margin-top: 16px; padding: 12px; border-radius: 10px; display: none; }
.msg.ok { display: block; background: #ecfdf5; color: #047857; }
.msg.err { display: block; background: #fef2f2; color: #b91c1c; }
</style>
</head>
<body>
<div class="card">
<h1>客户下单</h1>
<p class="sub">请填写订单信息,提交后将保存到 Page Data。</p>
<form id="orderForm">
<label>客户姓名 *</label><input id="customer_name" required>
<label>联系电话 *</label><input id="phone" required>
<label>商品名称 *</label><input id="product_name" required>
<label>购买数量 *</label><input id="quantity" type="number" min="1" value="1" required>
<label>收货地址 *</label><textarea id="address" required></textarea>
<label>备注</label><textarea id="remark"></textarea>
<button type="submit" id="submitBtn">提交订单</button>
</form>
<div class="msg" id="message"></div>
</div>
<script src="/assets/page-data-client.js"></script>
<script>
(function () {
var client = MindSpacePageData.createClient({ apiBase: '/api' });
var form = document.getElementById('orderForm');
var message = document.getElementById('message');
form.addEventListener('submit', async function (e) {
e.preventDefault();
message.className = 'msg';
message.style.display = 'none';
var payload = {
customer_name: document.getElementById('customer_name').value.trim(),
phone: document.getElementById('phone').value.trim(),
product_name: document.getElementById('product_name').value.trim(),
quantity: String(document.getElementById('quantity').value || '1'),
address: document.getElementById('address').value.trim(),
remark: document.getElementById('remark').value.trim(),
status: '待处理'
};
if (!payload.customer_name || !payload.phone || !payload.product_name || !payload.address) {
message.className = 'msg err'; message.textContent = '请填写所有必填项'; message.style.display = 'block'; return;
}
document.getElementById('submitBtn').disabled = true;
try {
await client.insertRow('customer_orders', payload);
message.className = 'msg ok'; message.textContent = '下单成功!';
message.style.display = 'block';
form.reset();
document.getElementById('quantity').value = '1';
} catch (err) {
message.className = 'msg err'; message.textContent = '提交失败:' + (err.message || err);
message.style.display = 'block';
} finally {
document.getElementById('submitBtn').disabled = false;
}
});
})();
</script>
</body>
</html>`;
const ORDER_ADMIN_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单管理后台</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f1f5f9; min-height: 100vh; padding: 24px 16px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.hero { background: #0f766e; color: #fff; border-radius: 14px; padding: 24px; margin-bottom: 16px; }
.panel { background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 24px rgba(15,23,42,.06); }
input, button { font: inherit; }
.auth input { width: 100%; max-width: 280px; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; margin-right: 8px; }
.auth button, .toolbar button { padding: 10px 16px; border: 0; border-radius: 8px; background: #0f766e; color: #fff; cursor: pointer; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th, td { padding: 10px 8px; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
th { background: #f8fafc; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0; }
.stat { background: #ecfdf5; color: #065f46; padding: 10px 14px; border-radius: 10px; font-size: 13px; }
.hidden { display: none; }
.err { color: #b91c1c; margin-top: 8px; }
</style>
</head>
<body>
<div class="wrap">
<div class="hero"><h1>客户订单管理后台</h1><p>查看订单、按商品统计、导出 CSV</p></div>
<div class="panel auth" id="authBox">
<h2 style="margin-bottom:12px">请输入管理口令</h2>
<input type="password" id="passwordInput" placeholder="管理口令">
<button id="authBtn">进入后台</button>
<div class="err hidden" id="authError"></div>
</div>
<div class="panel hidden" id="dash">
<div class="toolbar">
<button id="refreshBtn">刷新</button>
<button id="exportBtn">导出 CSV</button>
</div>
<div class="stats" id="stats"></div>
<table>
<thead><tr><th>#</th><th>客户</th><th>电话</th><th>商品</th><th>数量</th><th>地址</th><th>状态</th><th>时间</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script src="/assets/page-data-client.js"></script>
<script>
(function () {
var client = MindSpacePageData.createClient({ apiBase: '/api' });
var rowsCache = [];
document.getElementById('authBtn').addEventListener('click', async function () {
try {
await client.authenticate(document.getElementById('passwordInput').value.trim());
document.getElementById('authBox').classList.add('hidden');
document.getElementById('dash').classList.remove('hidden');
await loadRows();
} catch (e) {
var err = document.getElementById('authError');
err.textContent = '口令错误';
err.classList.remove('hidden');
}
});
document.getElementById('refreshBtn').addEventListener('click', loadRows);
document.getElementById('exportBtn').addEventListener('click', exportCsv);
async function loadRows() {
var result = await client.listRows('customer_orders', { limit: 200 });
rowsCache = result.rows || [];
renderStats(rowsCache);
renderTable(rowsCache);
}
function renderStats(rows) {
var counts = {};
rows.forEach(function (r) { counts[r.product_name] = (counts[r.product_name] || 0) + 1; });
document.getElementById('stats').innerHTML = Object.keys(counts).map(function (k) {
return '<div class="stat">' + k + '' + counts[k] + ' 单</div>';
}).join('') || '<div class="stat">暂无订单</div>';
}
function renderTable(rows) {
var tbody = document.getElementById('tbody');
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="8">暂无数据</td></tr>'; return; }
tbody.innerHTML = rows.map(function (r, i) {
return '<tr><td>' + (i+1) + '</td><td>' + esc(r.customer_name) + '</td><td>' + esc(r.phone) +
'</td><td>' + esc(r.product_name) + '</td><td>' + esc(r.quantity) + '</td><td>' + esc(r.address) +
'</td><td>' + esc(r.status) + '</td><td>' + esc(r.created_at || '') + '</td></tr>';
}).join('');
}
function exportCsv() {
var header = ['customer_name','phone','product_name','quantity','address','remark','status','created_at'];
var lines = [header.join(',')].concat(rowsCache.map(function (r) {
return header.map(function (k) { return '"' + String(r[k] ?? '').replace(/"/g, '""') + '"'; }).join(',');
}));
var blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8' });
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'customer-orders.csv'; a.click();
}
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]); }); }
})();
</script>
</body>
</html>`;
const FORM_POLICY = {
accessMode: 'public',
datasets: {
customer_orders: {
insert: true,
read: false,
columns: {
insert: ['customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status'],
},
},
},
};
const ADMIN_POLICY = {
accessMode: 'password',
datasets: {
customer_orders: {
insert: false,
read: true,
columns: {
read: ['id', 'customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status', 'created_at'],
},
},
},
};
async function main() {
const publicDir = path.join(WORKSPACE_ROOT, 'public');
await fs.mkdir(publicDir, { recursive: true });
await fs.writeFile(path.join(publicDir, 'customer-order-form.html'), ORDER_FORM_HTML, 'utf8');
await fs.writeFile(path.join(publicDir, 'customer-order-admin.html'), ORDER_ADMIN_HTML, 'utf8');
const dataSpace = createUserDataSpaceService({ workspaceRoot: WORKSPACE_ROOT, userId: USER_ID });
await dataSpace.executeSql(`CREATE TABLE IF NOT EXISTS customer_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
quantity TEXT NOT NULL DEFAULT '1',
address TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '待处理',
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);`);
await dataSpace.upsertDataset({
name: DATASET,
table: DATASET,
description: '客户下单系统订单表',
actions: ['read', 'insert'],
columns: {
insert: FORM_POLICY.datasets.customer_orders.columns.insert,
read: ADMIN_POLICY.datasets.customer_orders.columns.read,
},
});
const pool = createDbPool();
const storageRoot = resolveMindSpaceStorageRoot(root);
const pages = [
{ relativePath: 'public/customer-order-form.html', accessMode: 'public', password: null, policy: FORM_POLICY },
{ relativePath: 'public/customer-order-admin.html', accessMode: 'password', password: ADMIN_PASSWORD, policy: ADMIN_POLICY },
];
console.log('==> 客户下单系统 Page Data 演示\n');
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root: root,
storageRoot,
userId: USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` URL: ${result.workspaceUrl}\n`);
}
console.log('后台口令:', ADMIN_PASSWORD);
await pool.end();
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* 验证 john4 客户下单系统 Page Data 链路(insert + admin read)。
*/
import { loadH5Environment } from './load-env.mjs';
import {
createReporter,
loginViaApi,
resolvePortalBase,
} from './scenario-test-lib.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
import fs from 'node:fs/promises';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
const DATASET = 'customer_orders';
const FORM_PAGE = 'customer-order-form.html';
const ADMIN_PAGE = 'customer-order-admin.html';
loadH5Environment(import.meta.dirname);
async function findPageIdFromPolicy(publishKey) {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
const entries = await fs.readdir(policyDir);
for (const name of entries.filter((item) => item.endsWith('.json'))) {
const raw = await fs.readFile(path.join(policyDir, name), 'utf8');
const policy = JSON.parse(raw);
if (policy?.datasets?.[DATASET]?.insert) {
return { pageId: policy.pageId, fileName: name };
}
}
return null;
}
async function main() {
const port = Number(process.env.H5_PORT ?? 8081);
const baseUrl = resolvePortalBase(port);
const reporter = createReporter();
console.log('==> 客户下单系统 Page Data 验证');
console.log(` Portal: ${baseUrl}`);
console.log(` 用户: ${USER_ID}\n`);
const auth = await loginViaApi(baseUrl, { username: 'john4', password: '888888' }, reporter);
const publishKey = auth.user?.id ?? USER_ID;
const formUrl = `${baseUrl}/MindSpace/${publishKey}/public/${FORM_PAGE}`;
const adminUrl = `${baseUrl}/MindSpace/${publishKey}/public/${ADMIN_PAGE}`;
for (const [label, url] of [['下单页', formUrl], ['后台页', adminUrl]]) {
const res = await fetch(url, { headers: { Cookie: auth.cookie } });
const html = await res.text();
if (res.status !== 200) {
reporter.fail(`${label} HTTP`, `${res.status}`);
} else {
reporter.pass(`${label} HTTP 200`, url);
}
if (!html.includes('page-data-client.js')) {
reporter.fail(`${label} 脚本`, '缺少 page-data-client.js');
} else {
reporter.pass(`${label} Page Data 客户端`, '已引用');
}
}
const formMeta = await findPageIdFromPolicy(publishKey);
if (!formMeta?.pageId) {
reporter.fail('Page Data policy', '未找到 customer_orders insert policy');
process.exit(reporter.summary());
}
reporter.pass('Page Data policy', `${formMeta.fileName} pageId=${formMeta.pageId}`);
const insertPayload = {
customer_name: '测试客户',
phone: '13800138000',
product_name: '演示商品A',
quantity: '2',
address: '上海市浦东新区测试路 1 号',
remark: '自动化验证',
status: '待处理',
};
const insertRes = await fetch(
`${baseUrl}/api/public/pages/${formMeta.pageId}/data/${DATASET}/rows`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
body: JSON.stringify(insertPayload),
},
);
const insertBody = await insertRes.json().catch(() => ({}));
if (!insertRes.ok) {
reporter.fail('下单 insert', `${insertRes.status} ${JSON.stringify(insertBody)}`);
} else {
reporter.pass('下单 insert', `row id=${insertBody?.data?.id ?? insertBody?.id ?? 'ok'}`);
}
const adminMeta = await (async () => {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
const entries = await fs.readdir(policyDir);
for (const name of entries.filter((item) => item.endsWith('.json'))) {
const policy = JSON.parse(await fs.readFile(path.join(policyDir, name), 'utf8'));
if (policy?.datasets?.[DATASET]?.read) return policy.pageId;
}
return null;
})();
if (!adminMeta) {
reporter.fail('后台 policy', '未找到 read policy');
} else {
const tokenRes = await fetch(`${baseUrl}/api/public/pages/${adminMeta}/data-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
body: JSON.stringify({ password: '88888888' }),
});
const tokenBody = await tokenRes.json().catch(() => ({}));
const token = tokenBody?.data?.token ?? tokenBody?.token;
if (!tokenRes.ok || !token) {
reporter.fail('后台口令认证', `${tokenRes.status}`);
} else {
const listRes = await fetch(
`${baseUrl}/api/public/pages/${adminMeta}/data/${DATASET}?limit=20`,
{ headers: { 'x-page-data-token': token, Cookie: auth.cookie } },
);
const listBody = await listRes.json().catch(() => ({}));
const rows = listBody?.data?.rows ?? listBody?.rows ?? [];
const hit = rows.some((row) => row.customer_name === insertPayload.customer_name && row.product_name === insertPayload.product_name);
if (!listRes.ok || !hit) {
reporter.fail('后台读订单', `rows=${rows.length}, 未找到测试订单`);
} else {
reporter.pass('后台读订单', `${rows.length} 条,含测试订单`);
}
}
}
const sqlitePath = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'private-data.sqlite');
try {
await fs.stat(sqlitePath);
reporter.pass('用户数据空间', 'private-data.sqlite 存在');
} catch {
reporter.pass('用户数据空间', 'PostgreSQL 模式(无本地 sqlite 文件)');
}
console.log('\n--- 访问入口 ---');
console.log(`下单页: ${formUrl}`);
console.log(`后台页: ${adminUrl} (口令 88888888`);
process.exit(reporter.summary());
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});