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:
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verify(Phase 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 用户 ID(workspace 场景)',
|
||||
' --cwd <dir> 覆盖 Aider 工作目录',
|
||||
' --target-file <path> 可多次指定;未指定时用 preset 默认',
|
||||
' --max-attempts <n> 最大修复轮次(默认 3)',
|
||||
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 2,0=禁用)',
|
||||
' --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_URL(Aider 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,
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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(', ') || '(空)'}`);
|
||||
|
||||
@@ -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 ({'&':'&','<':'<','>':'>','"':'"'}[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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user