Extract memind_adm admin server, add local dev tooling, and remove image-generation.
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,49 @@
|
||||
node_modules
|
||||
dist
|
||||
# ============================================================
|
||||
# rsync 排除规则:Studio(100) → 105 生产
|
||||
# 在 Studio 上执行,路径相对于 ~/Project/Memind
|
||||
# ============================================================
|
||||
|
||||
# ── 机密配置(105 有自己的 .env,含独立 API_TARGET 等)──
|
||||
.env
|
||||
|
||||
# ── 生产用户数据 ──────────────────────────────────────────
|
||||
# MindSpace:105 经 rclone 挂载共享,不能用 rsync 覆盖
|
||||
# data/mindspace:同上,共享挂载树
|
||||
# users:105 用 /Users/john/Project/Memind/users 空目录占位
|
||||
MindSpace/
|
||||
data/
|
||||
users/
|
||||
|
||||
# ── 运行时临时文件 ────────────────────────────────────────
|
||||
temp/
|
||||
*.pid
|
||||
.h5.pid
|
||||
*.log
|
||||
MindSpace
|
||||
data/mindspace
|
||||
data/mindspace.bak*
|
||||
temp
|
||||
.git
|
||||
h5.log
|
||||
|
||||
# ── 构建产物(dist 单独同步;node_modules 在 105 上安装)─
|
||||
node_modules/
|
||||
ops/node_modules/
|
||||
|
||||
# ── 版本控制 ──────────────────────────────────────────────
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
users
|
||||
|
||||
# ── Studio 本机专用配置(105 不需要)─────────────────────
|
||||
# LaunchAgent 相关脚本只在 Studio(macOS) 有意义
|
||||
scripts/install-*.sh
|
||||
scripts/memind-mac-tunnel.sh
|
||||
scripts/memind-fwd-tunnel.sh
|
||||
# LB 由 Studio 的 Caddy 管理,105 上不需要这些文件
|
||||
scripts/g2-lb.Caddyfile
|
||||
scripts/g2-lb.Caddyfile.active
|
||||
scripts/g2-lb.Caddyfile.bak-*
|
||||
scripts/install-g2-lb.sh
|
||||
# Studio 生产备份不同步到 105
|
||||
server.mjs.bak-*
|
||||
*.bak-20*
|
||||
|
||||
# ── 本地开发专用 ──────────────────────────────────────────
|
||||
.claude/
|
||||
.local/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# ============================================================
|
||||
# rsync 排除规则:本地 dev → Studio(100) 生产
|
||||
# 所有列出的路径均相对于项目根目录
|
||||
# ============================================================
|
||||
|
||||
# ── 机密配置(绝不覆盖,Studio 自己维护)──────────────────
|
||||
.env
|
||||
|
||||
# ── 生产用户数据(绝对不能覆盖)──────────────────────────
|
||||
MindSpace/
|
||||
data/
|
||||
users/
|
||||
|
||||
# ── 运行时临时文件 ────────────────────────────────────────
|
||||
temp/
|
||||
*.pid
|
||||
.h5.pid
|
||||
*.log
|
||||
h5.log
|
||||
|
||||
# ── 构建产物(在 Studio 本地构建,不从 dev 覆盖)─────────
|
||||
node_modules/
|
||||
ops/node_modules/
|
||||
dist/
|
||||
ops/dist/
|
||||
|
||||
# ── 版本控制 ──────────────────────────────────────────────
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
# ── 本地开发专用(不上生产)──────────────────────────────
|
||||
.claude/
|
||||
.local/
|
||||
|
||||
# ── Studio LaunchAgent 启动脚本(由 plist 直接引用,删除即服务无法重启)──
|
||||
scripts/run-memind-portal-prod.sh
|
||||
scripts/run-plaza-prod.sh
|
||||
scripts/memind-fwd-tunnel.sh
|
||||
scripts/install-prod-services.sh
|
||||
|
||||
# ── 生产运营工具(只在 Studio 上使用,dev 无需保留)──────
|
||||
scripts/compensate-user-billing.mjs
|
||||
scripts/plaza-demo-posts.mjs
|
||||
|
||||
# ── 生产媒体资源(运营上传,dev 仓库不维护)──────────────
|
||||
public/plaza-covers/
|
||||
|
||||
# ── Studio 生产端独立管理的配置(不从 dev 覆盖)──────────
|
||||
# LB 权重配置直接在 Studio 上用 caddy reload 调整
|
||||
scripts/g2-lb.Caddyfile
|
||||
scripts/g2-lb.Caddyfile.active
|
||||
scripts/g2-lb.Caddyfile.bak-*
|
||||
# 生产备份,只在 Studio 存在
|
||||
server.mjs.bak-*
|
||||
*.bak-20*
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import {
|
||||
ADMIN_HOST,
|
||||
allHostsConfigured,
|
||||
H5_HOST,
|
||||
H5_PUBLIC_BASE,
|
||||
OPS_HOST,
|
||||
PLAZA_HOST,
|
||||
PLAZA_PUBLIC_BASE,
|
||||
PUBLIC_PORT,
|
||||
USES_LOCALHOST,
|
||||
publicUrl,
|
||||
} from './local-test-config.mjs';
|
||||
|
||||
function checkUrl(url) {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve) => {
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false,
|
||||
headers: parsed.hostname === PLAZA_HOST ? { Host: `${PLAZA_HOST}:${process.env.PLAZA_PORT ?? 3001}` } : {},
|
||||
},
|
||||
(res) => resolve(res.statusCode ?? 0),
|
||||
);
|
||||
req.on('error', () => resolve(0));
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve(0);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('==> 本地开发环境诊断');
|
||||
console.log(` 模式:${USES_LOCALHOST ? '*.localhost (:8443)' : 'custom tkmind.cn domains'}`);
|
||||
console.log('');
|
||||
|
||||
const issues = [];
|
||||
|
||||
if (!USES_LOCALHOST && !allHostsConfigured()) {
|
||||
issues.push('DNS 未配置 → sudo pnpm setup:local-test');
|
||||
} else if (USES_LOCALHOST) {
|
||||
console.log('✓ *.localhost 无需 /etc/hosts');
|
||||
} else {
|
||||
for (const host of [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]) {
|
||||
console.log(`✓ DNS ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
const tlsDir = new URL('../.local/local-test-tls/', import.meta.url);
|
||||
if (!fs.existsSync(new URL('./local-test.cert.pem', tlsDir))) {
|
||||
issues.push('TLS 证书缺失 → pnpm setup:local-test');
|
||||
} else {
|
||||
console.log('✓ TLS 证书已生成');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`==> HTTPS 入口 (:${PUBLIC_PORT},pnpm dev:local-proxy)`);
|
||||
|
||||
const checks = [
|
||||
['H5', `${H5_PUBLIC_BASE}/`, 200],
|
||||
['memind_adm', `${publicUrl(ADMIN_HOST)}/healthz`, 200],
|
||||
['Plaza', `${PLAZA_PUBLIC_BASE}/plaza`, 200],
|
||||
['Ops', `${publicUrl(OPS_HOST)}/ops/`, 200],
|
||||
];
|
||||
|
||||
for (const [label, url, expected] of checks) {
|
||||
const code = await checkUrl(url);
|
||||
if (code === expected || (label === 'Plaza' && [200, 307, 308].includes(code))) {
|
||||
console.log(`✓ ${label.padEnd(12)} ${url} → ${code}`);
|
||||
} else {
|
||||
console.log(`✗ ${label.padEnd(12)} ${url} → ${code || '不可达'}`);
|
||||
if (code === 0) {
|
||||
issues.push(`${label} 不可达:pnpm dev && pnpm dev:local-proxy`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (issues.length) {
|
||||
console.log('待处理:');
|
||||
for (const issue of issues) console.log(` - ${issue}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('本地开发环境正常。');
|
||||
console.log(`打开 H5:pnpm open:local-test`);
|
||||
+29
-120
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -13,20 +11,9 @@ const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const opsPort = Number(process.env.OPS_PORT ?? 3002);
|
||||
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const plazaPublicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443);
|
||||
const plazaPublicScheme = String(process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn').startsWith('http://')
|
||||
? 'http'
|
||||
: 'https';
|
||||
const plazaPublicBase = (
|
||||
process.env.PLAZA_PUBLIC_BASE ??
|
||||
(plazaPublicPort === 80 || plazaPublicPort === 443
|
||||
? `${plazaPublicScheme}://${plazaHost}`
|
||||
: `${plazaPublicScheme}://${plazaHost}:${plazaPublicPort}`)
|
||||
).replace(/\/$/, '');
|
||||
const plazaUrl = plazaPublicBase;
|
||||
const opsUrl = `http://127.0.0.1:${opsPort}`;
|
||||
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
@@ -69,8 +56,8 @@ function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
|
||||
}
|
||||
|
||||
let server;
|
||||
let admin;
|
||||
let plaza;
|
||||
let plazaProxy;
|
||||
let ops;
|
||||
let vite;
|
||||
let stopping = false;
|
||||
@@ -79,8 +66,8 @@ function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
server?.kill('SIGTERM');
|
||||
admin?.kill('SIGTERM');
|
||||
plaza?.kill('SIGTERM');
|
||||
plazaProxy?.kill('SIGTERM');
|
||||
ops?.kill('SIGTERM');
|
||||
vite?.kill('SIGTERM');
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
@@ -102,7 +89,11 @@ async function waitFor(url, check, label, retries = 60) {
|
||||
}
|
||||
|
||||
const mindSpacePublicBase = (
|
||||
process.env.H5_PUBLIC_BASE_URL ?? `http://localhost:${vitePort}`
|
||||
process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const plazaPublicBase = (
|
||||
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const plazaEnv = {
|
||||
@@ -122,6 +113,7 @@ const viteEnv = {
|
||||
|
||||
const opsEnv = {
|
||||
OPS_API_PROXY: portalUrl,
|
||||
OPS_ADMIN_PROXY: adminUrl,
|
||||
VITE_PLAZA_BASE: plazaPublicBase,
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
};
|
||||
@@ -132,86 +124,9 @@ function ensurePlazaApp() {
|
||||
}
|
||||
}
|
||||
|
||||
function plazaPublicReachable() {
|
||||
const url = new URL(`${plazaPublicBase}/plaza`);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const port = url.port || (isHttps ? 443 : 80);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transport = isHttps ? https : http;
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode === 200 || res.statusCode === 307 || res.statusCode === 308);
|
||||
},
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePlazaPublicProxy() {
|
||||
if (await plazaPublicReachable()) {
|
||||
console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`==> 尝试启动 Plaza 80 端口代理 (${plazaPublicBase})...`);
|
||||
const proxyScript = path.join(root, 'scripts/plaza-local-proxy.mjs');
|
||||
plazaProxy = spawn('node', [proxyScript], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let proxyFailed = false;
|
||||
plazaProxy.stdout?.on('data', (chunk) => process.stdout.write(`[plaza-proxy] ${chunk}`));
|
||||
plazaProxy.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk);
|
||||
process.stderr.write(`[plaza-proxy] ${text}`);
|
||||
if (/EACCES|EADDRINUSE|需要 root/.test(text)) proxyFailed = true;
|
||||
});
|
||||
plazaProxy.on('exit', (code) => {
|
||||
if (code && code !== 0) proxyFailed = true;
|
||||
});
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (proxyFailed) break;
|
||||
if (await plazaPublicReachable()) {
|
||||
console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
console.warn('');
|
||||
console.warn(`⚠ 无法在本进程绑定 ${plazaPublicPort} 端口,${plazaPublicBase} 暂不可用`);
|
||||
console.warn(' 请先:sudo pnpm setup:plaza-local');
|
||||
console.warn(' 再另开终端:sudo pnpm dev:plaza-proxy');
|
||||
console.warn('');
|
||||
}
|
||||
|
||||
function plazaHostConfigured(host) {
|
||||
try {
|
||||
const hosts = fs.readFileSync('/etc/hosts', 'utf8');
|
||||
return new RegExp(`^\\s*127\\.0\\.0\\.1\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test(hosts);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`==> 清理端口 ${portalPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`);
|
||||
console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`);
|
||||
freePort(portalPort);
|
||||
freePort(adminPort);
|
||||
freePort(vitePort);
|
||||
freePort(plazaPort);
|
||||
freePort(opsPort);
|
||||
@@ -219,13 +134,6 @@ await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
ensurePlazaApp();
|
||||
|
||||
if (!plazaHostConfigured(plazaHost)) {
|
||||
console.warn('');
|
||||
console.warn(`⚠ ${plazaHost} 尚未写入 /etc/hosts,浏览器无法打开 ${plazaPublicBase}`);
|
||||
console.warn(' 请执行:sudo pnpm setup:plaza-dns');
|
||||
console.warn('');
|
||||
}
|
||||
|
||||
console.log('==> 启动 portal (server.mjs)...');
|
||||
server = spawnChild('node', ['server.mjs'], 'portal');
|
||||
|
||||
@@ -233,7 +141,12 @@ try {
|
||||
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
|
||||
console.log(`==> Portal 就绪: ${portalUrl}`);
|
||||
|
||||
console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`);
|
||||
console.log('==> 启动 memind_adm (admin-server.mjs)...');
|
||||
admin = spawnChild('node', ['admin-server.mjs'], 'admin');
|
||||
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
|
||||
console.log(`==> memind_adm 就绪: ${adminUrl}`);
|
||||
|
||||
console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase}/plaza`);
|
||||
plaza = spawnChild(
|
||||
'npm',
|
||||
['run', 'dev', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
|
||||
@@ -244,28 +157,25 @@ try {
|
||||
await waitFor(
|
||||
`http://127.0.0.1:${plazaPort}`,
|
||||
async (url) => {
|
||||
const res = await fetch(`${url}/plaza`, {
|
||||
headers: { Host: `${plazaHost}:${plazaPort}` },
|
||||
});
|
||||
const res = await fetch(`${url}/plaza`);
|
||||
return res.ok || res.status === 307 || res.status === 308;
|
||||
},
|
||||
'Plaza',
|
||||
80,
|
||||
);
|
||||
console.log(`==> Plaza 内部就绪: http://127.0.0.1:${plazaPort}/plaza`);
|
||||
await ensurePlazaPublicProxy();
|
||||
console.log(`==> Plaza 就绪: http://127.0.0.1:${plazaPort}/plaza`);
|
||||
|
||||
console.log(`==> 启动 Ops 后台 @ ${opsUrl}/ops/`);
|
||||
console.log(`==> 启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`);
|
||||
ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv);
|
||||
await waitFor(
|
||||
`http://localhost:${opsPort}`,
|
||||
`http://127.0.0.1:${opsPort}`,
|
||||
async (url) => (await fetch(`${url}/ops/`)).ok,
|
||||
'Ops',
|
||||
40,
|
||||
);
|
||||
console.log(`==> Ops 就绪: http://localhost:${opsPort}/ops/`);
|
||||
console.log(`==> Ops 就绪: http://127.0.0.1:${opsPort}/ops/`);
|
||||
|
||||
console.log(`==> 启动 Vite @ http://localhost:${vitePort}`);
|
||||
console.log(`==> 启动 Vite @ http://127.0.0.1:${vitePort}`);
|
||||
vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv);
|
||||
await waitFor(
|
||||
`http://127.0.0.1:${vitePort}`,
|
||||
@@ -278,16 +188,15 @@ try {
|
||||
},
|
||||
'Vite',
|
||||
);
|
||||
console.log(`==> Vite 就绪: http://localhost:${vitePort}`);
|
||||
console.log(`==> Vite 就绪: http://127.0.0.1:${vitePort}`);
|
||||
|
||||
console.log('');
|
||||
console.log('本地服务:');
|
||||
console.log(` MindSpace UI http://localhost:${vitePort}/?preview=mindspace`);
|
||||
console.log(` Plaza 广场 ${plazaPublicBase}/plaza`);
|
||||
console.log(` Plaza 内部 http://127.0.0.1:${plazaPort}/plaza`);
|
||||
console.log(` (setup) sudo pnpm setup:plaza-local && sudo pnpm dev:plaza-proxy`);
|
||||
console.log(` Ops 审核后台 http://localhost:${opsPort}/ops/`);
|
||||
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
|
||||
console.log(` Plaza 广场 http://127.0.0.1:${plazaPort}/plaza`);
|
||||
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
|
||||
console.log(` API / Portal ${portalUrl}`);
|
||||
console.log(` memind_adm ${adminUrl}`);
|
||||
console.log('');
|
||||
console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin');
|
||||
} catch (err) {
|
||||
|
||||
@@ -103,15 +103,13 @@ async function ensureBundle(conn, row, html) {
|
||||
async function main() {
|
||||
const pool = createDbPool();
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pp.id AS post_id, pp.title, pp.summary, pp.tags,
|
||||
c.slug AS category_slug,
|
||||
`SELECT pp.id AS post_id, pp.title,
|
||||
pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.user_id, pr.status AS pub_status,
|
||||
p.space_id, p.category_id, p.title AS page_title,
|
||||
pv.bundle_asset_id,
|
||||
(SELECT av.id FROM h5_asset_versions av
|
||||
WHERE av.asset_id = pv.bundle_asset_id AND av.version_no = 1 LIMIT 1) AS bundle_version_id
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_id
|
||||
JOIN h5_publish_records pr ON pr.id = pp.publication_id
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
JOIN h5_page_versions pv ON pv.id = pr.page_version_id
|
||||
@@ -124,17 +122,7 @@ async function main() {
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
for (const row of rows) {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags ?? [];
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title, {
|
||||
categorySlug: row.category_slug,
|
||||
summary: row.summary,
|
||||
tags,
|
||||
});
|
||||
const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title);
|
||||
const summaryOnly = skipHtml && row.bundle_asset_id;
|
||||
if (!summaryOnly) {
|
||||
await ensureBundle(conn, row, html);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Point Memind LLM + local goosed (127.0.0.1:18006) at a working relay profile.
|
||||
*
|
||||
* Prefers ~/.config/goose/custom_providers/andu_deepseek.json (same as Goose Desktop),
|
||||
* then H5_LOCAL_RELAY_* env overrides.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/fix-local-goose-relay.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import {
|
||||
createLlmProviderService,
|
||||
resolveChatCompletionsUrl,
|
||||
testRelayConnection,
|
||||
} from '../llm-providers.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function loadGooseAnduDeepseekProfile() {
|
||||
const profilePath = path.join(
|
||||
os.homedir(),
|
||||
'.config/goose/custom_providers/andu_deepseek.json',
|
||||
);
|
||||
if (!fs.existsSync(profilePath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
||||
const authHeader = raw?.headers?.Authorization ?? '';
|
||||
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
||||
const baseUrl = String(raw?.base_url ?? '').trim();
|
||||
if (!token || !baseUrl) return null;
|
||||
const models = (raw?.models ?? []).map((item) => item.name).filter(Boolean);
|
||||
return {
|
||||
name: raw.display_name ?? 'Local Goose DeepSeek',
|
||||
apiUrl: resolveChatCompletionsUrl(baseUrl),
|
||||
apiKey: token,
|
||||
models: models.length ? models : ['deepseek-chat'],
|
||||
defaultModel: raw?.models?.[0]?.name ?? 'deepseek-chat',
|
||||
relayProvider: 'deepseek',
|
||||
};
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const gooseProfile = loadGooseAnduDeepseekProfile();
|
||||
const relayUrl =
|
||||
process.env.H5_LOCAL_RELAY_URL ??
|
||||
gooseProfile?.apiUrl ??
|
||||
'http://127.0.0.1:18300/relay/buyer/v1/chat/completions';
|
||||
const relayToken =
|
||||
process.env.H5_LOCAL_RELAY_TOKEN ?? gooseProfile?.apiKey ?? '';
|
||||
const relayModel = process.env.H5_LOCAL_RELAY_MODEL ?? gooseProfile?.defaultModel ?? 'deepseek-chat';
|
||||
const relayModels = process.env.H5_LOCAL_RELAY_MODELS
|
||||
? process.env.H5_LOCAL_RELAY_MODELS.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: (gooseProfile?.models ?? ['deepseek-chat', 'deepseek-reasoner']);
|
||||
const configName =
|
||||
process.env.H5_LOCAL_RELAY_NAME ?? gooseProfile?.name ?? 'Local Goose DeepSeek';
|
||||
|
||||
if (!relayToken) {
|
||||
console.error('缺少 relay token:设置 H5_LOCAL_RELAY_TOKEN 或配置 ~/.config/goose/custom_providers/andu_deepseek.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
const svc = 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 [rows] = await pool.query(
|
||||
'SELECT id FROM h5_llm_provider_keys WHERE is_selected = 1 LIMIT 1',
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
console.error('未找到已启用的 LLM 配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = await svc.updateKey(id, {
|
||||
name: configName,
|
||||
apiUrl: relayUrl,
|
||||
apiKey: relayToken,
|
||||
models: relayModels,
|
||||
defaultModel: relayModel,
|
||||
relayProvider: 'deepseek',
|
||||
});
|
||||
|
||||
if (!updated.ok) {
|
||||
console.error('更新失败:', updated.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const synced = await svc.syncSelectedToGoosed();
|
||||
if (!synced.ok) {
|
||||
console.error('同步 goosed 失败:', synced.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const test = await testRelayConnection({
|
||||
apiUrl: relayUrl,
|
||||
apiKey: relayToken,
|
||||
model: relayModel,
|
||||
relayProvider: 'deepseek',
|
||||
});
|
||||
|
||||
console.log('LLM 已对接本地 goosed:', process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006');
|
||||
console.log('Relay:', relayUrl);
|
||||
console.log('goosed provider:', synced.providerId, 'model:', synced.model);
|
||||
console.log('连通测试:', test.ok ? `OK (${test.latencyMs}ms)` : test.message);
|
||||
|
||||
await pool.end();
|
||||
process.exit(test.ok ? 0 : 1);
|
||||
+10
-2
@@ -1,6 +1,14 @@
|
||||
# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105
|
||||
# g2.tkmind.cn 负载均衡:Studio 本机 H5 (:8081) 主 + 105 portal (经正向隧道 :18080) 灰度副
|
||||
# 权重 19:1 ≈ 95% / 5%;active 健康检查 /api/status,挂掉自动摘除
|
||||
:8090 {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
reverse_proxy {
|
||||
to 127.0.0.1:8081 127.0.0.1:18080
|
||||
lb_policy weighted_round_robin 19 1
|
||||
header_down +X-Memind-Upstream {http.reverse_proxy.upstream.hostport}
|
||||
health_uri /api/status
|
||||
health_interval 10s
|
||||
health_timeout 5s
|
||||
health_status 200
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared local test domain configuration.
|
||||
*
|
||||
* Default: *.localhost — browsers resolve these to 127.0.0.1 without /etc/hosts
|
||||
* or fighting Cloudflare wildcard DNS on *.tkmind.cn (which serves AndU publicly).
|
||||
*
|
||||
* h5.localhost → MindSpace H5 (Vite :5173)
|
||||
* adm.localhost → memind_adm (:8082)
|
||||
* pla.localhost → Plaza Next.js (:3001)
|
||||
* ops.localhost → Ops console SPA (:3002)
|
||||
*
|
||||
* HTTPS unified entry: https://<host>:8443 (pnpm dev:local-proxy, no sudo)
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
|
||||
export const LOCAL_IP = process.env.LOCAL_TEST_IP ?? '127.0.0.1';
|
||||
export const DNS_PORT = Number(process.env.LOCAL_TEST_DNS_PORT ?? 5533);
|
||||
export const DNS_MARKER = 'tkmind-local-test';
|
||||
|
||||
export const H5_HOST = process.env.H5_LOCAL_HOST ?? 'h5.localhost';
|
||||
export const ADMIN_HOST = process.env.ADMIN_LOCAL_HOST ?? 'adm.localhost';
|
||||
export const PLAZA_HOST = process.env.PLAZA_LOCAL_HOST ?? 'pla.localhost';
|
||||
export const OPS_HOST = process.env.OPS_LOCAL_HOST ?? 'ops.localhost';
|
||||
|
||||
export const TEST_HOSTS = [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST];
|
||||
|
||||
export const USES_LOCALHOST = TEST_HOSTS.every((host) => host === 'localhost' || host.endsWith('.localhost'));
|
||||
|
||||
export const PUBLIC_SCHEME = (
|
||||
process.env.LOCAL_TEST_SCHEME ??
|
||||
(String(process.env.LOCAL_TEST_HTTPS ?? '1') === '0' ? 'http' : 'https')
|
||||
).replace(/:$/, '');
|
||||
|
||||
export const PUBLIC_PORT = Number(
|
||||
process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === 'https' ? 443 : 80),
|
||||
);
|
||||
|
||||
export function publicUrl(host, { path = '' } = {}) {
|
||||
const defaultPort = PUBLIC_SCHEME === 'https' ? 443 : 80;
|
||||
const portSuffix = PUBLIC_PORT === defaultPort ? '' : `:${PUBLIC_PORT}`;
|
||||
const normalizedPath = path.startsWith('/') ? path : path ? `/${path}` : '';
|
||||
return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export const H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, '');
|
||||
export const ADMIN_PUBLIC_BASE = publicUrl(ADMIN_HOST).replace(/\/$/, '');
|
||||
export const PLAZA_PUBLIC_BASE = (process.env.PLAZA_PUBLIC_BASE ?? publicUrl(PLAZA_HOST)).replace(/\/$/, '');
|
||||
export const OPS_PUBLIC_BASE = publicUrl(OPS_HOST, { path: '/ops/' }).replace(/\/$/, '') + '/';
|
||||
|
||||
export function isLocalDevHostname(hostname) {
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname.endsWith('.localhost')
|
||||
);
|
||||
}
|
||||
|
||||
export function hostConfigured(host) {
|
||||
if (isLocalDevHostname(host)) return true;
|
||||
try {
|
||||
const hosts = fs.readFileSync('/etc/hosts', 'utf8');
|
||||
return new RegExp(`^\\s*${LOCAL_IP.replace('.', '\\.')}\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test(
|
||||
hosts,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolverConfigured(host) {
|
||||
if (isLocalDevHostname(host)) return true;
|
||||
try {
|
||||
const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8');
|
||||
return body.includes(DNS_MARKER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function allHostsConfigured() {
|
||||
return TEST_HOSTS.every((host) => hostConfigured(host) && resolverConfigured(host));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal DNS server for local test domains -> 127.0.0.1
|
||||
* Works with /etc/resolver/<host> (macOS per-domain DNS override).
|
||||
*/
|
||||
import dgram from 'node:dgram';
|
||||
import { DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs';
|
||||
|
||||
const ip = LOCAL_IP.split('.').map(Number);
|
||||
const bind = process.env.LOCAL_TEST_DNS_BIND ?? '127.0.0.1';
|
||||
const hosts = new Set(TEST_HOSTS.flatMap((host) => [host, `${host}.`]));
|
||||
|
||||
function encodeName(name) {
|
||||
const parts = name.split('.').filter(Boolean);
|
||||
return Buffer.concat([
|
||||
...parts.map((part) => Buffer.concat([Buffer.from([part.length]), Buffer.from(part, 'ascii')])),
|
||||
Buffer.from([0]),
|
||||
]);
|
||||
}
|
||||
|
||||
function readQuestionName(msg, offset) {
|
||||
const labels = [];
|
||||
let pos = offset;
|
||||
while (pos < msg.length) {
|
||||
const len = msg[pos];
|
||||
if (len === 0) {
|
||||
pos += 1;
|
||||
break;
|
||||
}
|
||||
labels.push(msg.subarray(pos + 1, pos + 1 + len).toString('ascii'));
|
||||
pos += 1 + len;
|
||||
}
|
||||
return { name: labels.join('.'), next: pos };
|
||||
}
|
||||
|
||||
function questionMatches(name) {
|
||||
return hosts.has(name);
|
||||
}
|
||||
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
if (msg.length < 12) return;
|
||||
|
||||
const qdCount = msg.readUInt16BE(4);
|
||||
if (qdCount !== 1) return;
|
||||
|
||||
const question = readQuestionName(msg, 12);
|
||||
const qtype = msg.readUInt16BE(question.next);
|
||||
const qclass = msg.readUInt16BE(question.next + 2);
|
||||
const questionEnd = question.next + 4;
|
||||
|
||||
if (!questionMatches(question.name) || (qtype !== 1 && qtype !== 28) || qclass !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const questionSection = msg.subarray(12, questionEnd);
|
||||
const ttl = Buffer.from([0x00, 0x00, 0x00, 0x3c]);
|
||||
|
||||
let answer;
|
||||
if (qtype === 1) {
|
||||
answer = Buffer.concat([
|
||||
Buffer.from([0xc0, 0x0c]),
|
||||
Buffer.from([0x00, 0x01, 0x00, 0x01]),
|
||||
ttl,
|
||||
Buffer.from([0x00, 0x04]),
|
||||
Buffer.from(ip),
|
||||
]);
|
||||
} else {
|
||||
answer = Buffer.concat([
|
||||
Buffer.from([0xc0, 0x0c]),
|
||||
Buffer.from([0x00, 0x1c, 0x00, 0x01]),
|
||||
ttl,
|
||||
Buffer.from([0x00, 0x10]),
|
||||
Buffer.from([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ip[0], ip[1], ip[2], ip[3],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt16BE(msg.readUInt16BE(0), 0);
|
||||
header.writeUInt16BE(0x8180, 2);
|
||||
header.writeUInt16BE(1, 4);
|
||||
header.writeUInt16BE(1, 6);
|
||||
header.writeUInt16BE(0, 8);
|
||||
header.writeUInt16BE(0, 10);
|
||||
|
||||
server.send(Buffer.concat([header, questionSection, answer]), rinfo.port, rinfo.address);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Local test DNS 端口 ${DNS_PORT} 已被占用`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.bind(DNS_PORT, bind, () => {
|
||||
console.log(`Local test DNS ${TEST_HOSTS.join(', ')} -> ${LOCAL_IP} @ ${bind}:${DNS_PORT}`);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Unified HTTPS reverse proxy for local test domains.
|
||||
*
|
||||
* test.tkmind.cn -> Vite :5173 (H5 + HMR)
|
||||
* testadm.tkmind.cn -> admin :8082
|
||||
* testpla.tkmind.cn -> Plaza :3001
|
||||
* testops.tkmind.cn -> Ops :3002
|
||||
*
|
||||
* Requires root on macOS for :443:
|
||||
* sudo pnpm dev:local-proxy
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ADMIN_HOST,
|
||||
H5_HOST,
|
||||
OPS_HOST,
|
||||
PLAZA_HOST,
|
||||
PUBLIC_PORT,
|
||||
PUBLIC_SCHEME,
|
||||
publicUrl,
|
||||
} from './local-test-config.mjs';
|
||||
import { ensureLocalTestTls } from './local-test-tls.mjs';
|
||||
|
||||
const listenHost = process.env.LOCAL_TEST_PROXY_BIND ?? '0.0.0.0';
|
||||
const targetHost = process.env.LOCAL_TEST_PROXY_TARGET ?? '127.0.0.1';
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const opsPort = Number(process.env.OPS_PORT ?? 3002);
|
||||
const useHttps = PUBLIC_SCHEME === 'https';
|
||||
|
||||
const ROUTES = {
|
||||
[H5_HOST.toLowerCase()]: { port: vitePort },
|
||||
[ADMIN_HOST.toLowerCase()]: { port: adminPort },
|
||||
[PLAZA_HOST.toLowerCase()]: { port: plazaPort, rewriteHost: true },
|
||||
[OPS_HOST.toLowerCase()]: { port: opsPort, rewriteHost: true },
|
||||
};
|
||||
|
||||
function routeFor(req) {
|
||||
const hostname = String(req.headers.host ?? '').split(':')[0].toLowerCase();
|
||||
return ROUTES[hostname] ? { hostname, ...ROUTES[hostname] } : null;
|
||||
}
|
||||
|
||||
function proxyRequest(clientReq, clientRes) {
|
||||
const route = routeFor(clientReq);
|
||||
if (!route) {
|
||||
clientRes.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
clientRes.end(`Unknown host: ${clientReq.headers.host ?? '(missing)'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { ...clientReq.headers };
|
||||
if (route.rewriteHost) {
|
||||
headers.host = `${route.hostname}:${route.port}`;
|
||||
}
|
||||
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: targetHost,
|
||||
port: route.port,
|
||||
method: clientReq.method,
|
||||
path: clientReq.url,
|
||||
headers,
|
||||
},
|
||||
(proxyRes) => {
|
||||
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
||||
proxyRes.pipe(clientRes);
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
}
|
||||
clientRes.end(
|
||||
`Upstream unavailable (${route.hostname} -> ${targetHost}:${route.port}): ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
clientReq.pipe(proxyReq);
|
||||
}
|
||||
|
||||
function proxyUpgrade(clientReq, clientSocket, head) {
|
||||
const route = routeFor(clientReq);
|
||||
if (!route) {
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { ...clientReq.headers };
|
||||
if (route.rewriteHost) {
|
||||
headers.host = `${route.hostname}:${route.port}`;
|
||||
}
|
||||
|
||||
const proxyReq = http.request({
|
||||
hostname: targetHost,
|
||||
port: route.port,
|
||||
method: clientReq.method,
|
||||
path: clientReq.url,
|
||||
headers,
|
||||
});
|
||||
|
||||
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
||||
clientSocket.write(
|
||||
`HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n` +
|
||||
Object.entries(proxyRes.headers)
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\r\n') +
|
||||
'\r\n\r\n',
|
||||
);
|
||||
if (proxyHead.length) proxySocket.write(proxyHead);
|
||||
proxySocket.pipe(clientSocket);
|
||||
clientSocket.pipe(proxySocket);
|
||||
});
|
||||
|
||||
proxyReq.on('error', () => clientSocket.destroy());
|
||||
proxyReq.end(head);
|
||||
}
|
||||
|
||||
function bindErrorHandler(server) {
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`无法监听 ${PUBLIC_PORT} 端口。若使用 443 需 sudo;默认 8443 无需 root。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`端口 ${PUBLIC_PORT} 已被占用。`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
let server;
|
||||
if (useHttps) {
|
||||
const { keyPath, certPath } = ensureLocalTestTls();
|
||||
server = https.createServer(
|
||||
{
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
},
|
||||
proxyRequest,
|
||||
);
|
||||
} else {
|
||||
server = http.createServer(proxyRequest);
|
||||
}
|
||||
|
||||
server.on('upgrade', proxyUpgrade);
|
||||
bindErrorHandler(server);
|
||||
|
||||
server.listen(PUBLIC_PORT, listenHost, () => {
|
||||
const scheme = useHttps ? 'https' : 'http';
|
||||
console.log(`Local test proxy (${scheme}:${PUBLIC_PORT})`);
|
||||
console.log(` ${publicUrl(H5_HOST)} -> :${vitePort} (H5)`);
|
||||
console.log(` ${publicUrl(ADMIN_HOST)} -> :${adminPort} (memind_adm)`);
|
||||
console.log(` ${publicUrl(PLAZA_HOST)}/plaza -> :${plazaPort} (Plaza)`);
|
||||
console.log(` ${publicUrl(OPS_HOST)}/ops/ -> :${opsPort} (Ops)`);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { H5_HOST, TEST_HOSTS } from './local-test-config.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function certReadable(keyPath, certPath) {
|
||||
try {
|
||||
fs.accessSync(keyPath, fs.constants.R_OK);
|
||||
fs.accessSync(certPath, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCertDir() {
|
||||
const candidates = [
|
||||
path.join(root, '.local/local-test-tls'),
|
||||
path.join(root, `.local/local-test-tls-${process.getuid?.() ?? 'user'}`),
|
||||
];
|
||||
for (const certDir of candidates) {
|
||||
const keyPath = path.join(certDir, 'local-test.key.pem');
|
||||
const certPath = path.join(certDir, 'local-test.cert.pem');
|
||||
if (certReadable(keyPath, certPath)) {
|
||||
return { certDir, keyPath, certPath };
|
||||
}
|
||||
}
|
||||
const certDir = path.join(root, `.local/local-test-tls-${process.getuid?.() ?? 'user'}`);
|
||||
return {
|
||||
certDir,
|
||||
keyPath: path.join(certDir, 'local-test.key.pem'),
|
||||
certPath: path.join(certDir, 'local-test.cert.pem'),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureLocalTestTls() {
|
||||
let { certDir, keyPath, certPath } = resolveCertDir();
|
||||
if (certReadable(keyPath, certPath)) {
|
||||
return { keyPath, certPath, certDir };
|
||||
}
|
||||
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
const sans = [
|
||||
...TEST_HOSTS.map((host) => `DNS:${host}`),
|
||||
'DNS:localhost',
|
||||
'IP:127.0.0.1',
|
||||
].join(',');
|
||||
const openssl = `openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
|
||||
-keyout "${keyPath}" -out "${certPath}" \
|
||||
-subj "/CN=${H5_HOST}" \
|
||||
-addext "subjectAltName=${sans}"`;
|
||||
|
||||
execSync(openssl, { stdio: 'inherit' });
|
||||
console.log(`已生成本地 TLS 证书:${certDir}`);
|
||||
console.log(`SAN: ${TEST_HOSTS.join(', ')}`);
|
||||
return { keyPath, certPath, certDir };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
ensureLocalTestTls();
|
||||
}
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080(goose-h5 portal)
|
||||
# 供 g2-lb Caddy 把一部分 g2 流量加权分到 105,同协议(HTTP) + SSH 加密私网
|
||||
set -euo pipefail
|
||||
exec ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -L 127.0.0.1:18080:127.0.0.1:8080 ssh105
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const url = `http://127.0.0.1:${vitePort}/?preview=mindspace`;
|
||||
|
||||
const result = spawnSync('open', [url], { stdio: 'ignore' });
|
||||
if (result.status === 0) {
|
||||
console.log(`已打开 ${url}`);
|
||||
} else {
|
||||
console.log(`请手动打开:${url}`);
|
||||
}
|
||||
@@ -845,21 +845,9 @@ export const ENRICHMENTS = {
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnrichment(postId, fallbackTitle, meta = {}) {
|
||||
export function getEnrichment(postId, fallbackTitle) {
|
||||
const item = ENRICHMENTS[postId];
|
||||
if (item) return item;
|
||||
|
||||
const categorySlug = meta.categorySlug ?? meta.category_slug ?? '';
|
||||
const summary = meta.summary ?? '';
|
||||
const tags = meta.tags ?? [];
|
||||
|
||||
if (categorySlug) {
|
||||
return {
|
||||
summary: summary || `${fallbackTitle} — MindSpace 精选内容`,
|
||||
html: buildCategoryPage(categorySlug, fallbackTitle, summary || fallbackTitle, tags),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: `${fallbackTitle} — MindSpace 精选`,
|
||||
html: buildJohnPage({
|
||||
@@ -872,215 +860,3 @@ export function getEnrichment(postId, fallbackTitle, meta = {}) {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORY_THEMES = {
|
||||
'work-report': {
|
||||
tag: '职场报告',
|
||||
tagEmoji: '📊',
|
||||
accent: '#2d7d6a',
|
||||
accent2: '#4ecdc4',
|
||||
images: [IMG.growth, IMG.ai, IMG.supply, IMG.guide],
|
||||
},
|
||||
'study-notes': {
|
||||
tag: '学习笔记',
|
||||
tagEmoji: '📚',
|
||||
accent: '#f97316',
|
||||
accent2: '#fb923c',
|
||||
images: [IMG.rust, IMG.llm, IMG.guide, IMG.opensource],
|
||||
},
|
||||
creative: {
|
||||
tag: '创意作品',
|
||||
tagEmoji: '🎨',
|
||||
accent: '#c084fc',
|
||||
accent2: '#a855f7',
|
||||
images: [IMG.cyber, IMG.music, IMG.guide],
|
||||
},
|
||||
business: {
|
||||
tag: '门店展示',
|
||||
tagEmoji: '🏪',
|
||||
accent: '#d97706',
|
||||
accent2: '#fbbf24',
|
||||
images: [IMG.coffee, IMG.pilates, IMG.guide],
|
||||
},
|
||||
travel: {
|
||||
tag: '旅行攻略',
|
||||
tagEmoji: '✈️',
|
||||
accent: '#2d7d6a',
|
||||
accent2: '#4ecdc4',
|
||||
images: [IMG.malaysia, IMG.kyoto, IMG.travel, IMG.guide],
|
||||
},
|
||||
'data-analysis': {
|
||||
tag: '数据分析',
|
||||
tagEmoji: '📈',
|
||||
accent: '#38bdf8',
|
||||
accent2: '#0ea5e9',
|
||||
images: [IMG.data, IMG.growth, IMG.guide],
|
||||
},
|
||||
lifestyle: {
|
||||
tag: '生活记录',
|
||||
tagEmoji: '🌿',
|
||||
accent: '#2d7d6a',
|
||||
accent2: '#4ecdc4',
|
||||
images: [IMG.desk, IMG.write, IMG.guide],
|
||||
},
|
||||
other: {
|
||||
tag: '其他',
|
||||
tagEmoji: '💡',
|
||||
accent: '#34d399',
|
||||
accent2: '#10b981',
|
||||
images: [IMG.opensource, IMG.wish, IMG.guide],
|
||||
},
|
||||
};
|
||||
|
||||
function hashTitle(title) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < title.length; i += 1) {
|
||||
hash = (hash * 31 + title.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function buildHeroTitleHtml(title) {
|
||||
const trimmed = String(title).trim();
|
||||
if (trimmed.length <= 6) {
|
||||
return `<em>${esc(trimmed)}</em>`;
|
||||
}
|
||||
const splitAt = Math.min(8, Math.ceil(trimmed.length * 0.42));
|
||||
const head = trimmed.slice(0, splitAt);
|
||||
const tail = trimmed.slice(splitAt);
|
||||
return `${esc(head)}<em>${esc(tail)}</em>`;
|
||||
}
|
||||
|
||||
function buildCategorySections(categorySlug, title, summary, tags) {
|
||||
const tagList =
|
||||
tags.length > 0
|
||||
? `<ul>${tags.map((tag) => `<li>${esc(tag)}</li>`).join('')}</ul>`
|
||||
: '';
|
||||
|
||||
const metricsPool = {
|
||||
'work-report': [
|
||||
{ value: '92%', label: '目标完成', note: '本期 OKR' },
|
||||
{ value: '3', label: '关键实验', note: '已验证假设' },
|
||||
{ value: '↓18%', label: '阻塞时长', note: '较上期' },
|
||||
],
|
||||
'study-notes': [
|
||||
{ value: '15min', label: '阅读时长', note: '核心章节' },
|
||||
{ value: '8', label: '要点', note: '可复用片段' },
|
||||
{ value: '3', label: '练习题', note: '附参考答案' },
|
||||
],
|
||||
creative: [
|
||||
{ value: '4K', label: '输出规格', note: '主视觉' },
|
||||
{ value: '3', label: '延展场景', note: '社媒 / 印刷' },
|
||||
{ value: '12', label: '配色', note: '品牌体系' },
|
||||
],
|
||||
business: [
|
||||
{ value: '+24%', label: '转化提升', note: '活动期' },
|
||||
{ value: '4.8', label: '评分', note: '5 分制' },
|
||||
{ value: '86%', label: '复购意向', note: '问卷样本' },
|
||||
],
|
||||
travel: [
|
||||
{ value: '5-10', label: '行程天数', note: '弹性安排' },
|
||||
{ value: '¥680', label: '日均预算', note: '舒适型参考' },
|
||||
{ value: '12+', label: '精选点位', note: '含美食' },
|
||||
],
|
||||
'data-analysis': [
|
||||
{ value: '3层', label: '下钻维度', note: '渠道/品类/客单' },
|
||||
{ value: '99%', label: '数据覆盖', note: '核心指标' },
|
||||
{ value: 'T+1', label: '刷新频率', note: '看板更新' },
|
||||
],
|
||||
lifestyle: [
|
||||
{ value: '21天', label: '挑战周期', note: '习惯养成' },
|
||||
{ value: '12', label: '实践要点', note: '可执行清单' },
|
||||
{ value: '↓32%', label: '焦虑自评', note: '挑战前后' },
|
||||
],
|
||||
other: [
|
||||
{ value: '6', label: '关键动作', note: '冷启动路径' },
|
||||
{ value: '1000+', label: '读者', note: '累计触达' },
|
||||
{ value: '4.6', label: '满意度', note: '反馈均值' },
|
||||
],
|
||||
};
|
||||
|
||||
const cardsPool = {
|
||||
'work-report': [
|
||||
{ title: '背景与目标', desc: '明确本期业务目标与衡量口径,对齐 stakeholder 预期。', tag: '目标' },
|
||||
{ title: '执行进展', desc: '按里程碑拆解完成度,标注风险与依赖项。', tag: '进展' },
|
||||
{ title: '数据洞察', desc: '用 3 个核心指标讲清「发生了什么、为什么」。', tag: '洞察' },
|
||||
{ title: '下一步', desc: '列出 2–3 个可验证的下一周期动作。', tag: '行动' },
|
||||
],
|
||||
'study-notes': [
|
||||
{ title: '核心概念', desc: '用一句话 + 生活类比建立直觉,再展开细节。', tag: '概念' },
|
||||
{ title: '常见误区', desc: '面试与实践中高频踩坑点对照表。', tag: '避坑' },
|
||||
{ title: '代码/例题', desc: '可复制片段,标注输入输出与边界条件。', tag: '实践' },
|
||||
],
|
||||
travel: [
|
||||
{ title: 'Day 1–2', desc: '抵达与市区精华,控制步行强度。', tag: '行程' },
|
||||
{ title: 'Day 3–4', desc: '深度体验与小众点位,预留弹性时间。', tag: '深度' },
|
||||
{ title: '美食地图', desc: '早/午/晚各 2 家备选,标注人均与排队时段。', tag: '美食' },
|
||||
],
|
||||
creative: [
|
||||
{ title: '概念阐述', desc: '设计意图、受众与使用场景说明。', tag: '概念' },
|
||||
{ title: '视觉规范', desc: '字体、配色、网格与留白规则。', tag: '规范' },
|
||||
{ title: '延展应用', desc: '海报、社媒、印刷等多场景适配方案。', tag: '延展' },
|
||||
],
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{
|
||||
type: 'metrics',
|
||||
title: '核心亮点',
|
||||
hint: title,
|
||||
items: metricsPool[categorySlug] ?? metricsPool.other,
|
||||
},
|
||||
];
|
||||
|
||||
if (cardsPool[categorySlug]) {
|
||||
sections.push({
|
||||
type: 'cards',
|
||||
title: '内容结构',
|
||||
hint: summary.slice(0, 60),
|
||||
items: cardsPool[categorySlug],
|
||||
});
|
||||
}
|
||||
|
||||
sections.push({
|
||||
type: 'text',
|
||||
title: '详细说明',
|
||||
html: `<p>${esc(summary)}</p>${tagList}`,
|
||||
});
|
||||
|
||||
sections.push({
|
||||
type: 'quote',
|
||||
html: `「${esc(title)}」—— 由 <span style="color:#4ecdc4">MindSpace</span> 创作并公开发布到 TKMind 广场。`,
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function buildCategoryPage(categorySlug, title, summary, tags = []) {
|
||||
const theme = CATEGORY_THEMES[categorySlug] ?? CATEGORY_THEMES.other;
|
||||
const heroImage = theme.images[hashTitle(title) % theme.images.length];
|
||||
const bgDark = categorySlug === 'creative' ? '#0f0a1a' : '#0a1628';
|
||||
|
||||
return buildJohnPage({
|
||||
title,
|
||||
description: summary,
|
||||
tag: theme.tag,
|
||||
tagEmoji: theme.tagEmoji,
|
||||
accent: theme.accent,
|
||||
accent2: theme.accent2,
|
||||
bgDark,
|
||||
heroImage,
|
||||
heroTitleHtml: buildHeroTitleHtml(title),
|
||||
heroSubtitle: summary,
|
||||
heroBtn: '开始阅读',
|
||||
sections: buildCategorySections(categorySlug, title, summary, tags),
|
||||
footer: 'TKMind Plaza · MindSpace 公开发布',
|
||||
});
|
||||
}
|
||||
|
||||
// legacy export — post_id keyed enrichments kept for existing seeded posts
|
||||
export function getEnrichmentLegacy(postId, fallbackTitle) {
|
||||
const item = ENRICHMENTS[postId];
|
||||
if (item) return item;
|
||||
return getEnrichment(postId, fallbackTitle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 通过 goose 子会话重新生成 MindSpace 页面内容(Studio / RDS 环境测试)
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/regenerate-page-goose-test.mjs [pageId] [parentSessionId]
|
||||
*
|
||||
* 默认:探索世界 · TKMind(john,source_session 20260614_70)
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
import { createPageService } from '../mindspace-pages.mjs';
|
||||
import { createPageLiveEditService } from '../mindspace-page-live-edit.mjs';
|
||||
import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { extractMindSpacePagePatch } from '../mindspace-page-patch.mjs';
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const PAGE_ID = process.argv[2] ?? '44a8d4f5-7dda-4e0c-973a-c580bf2cb82b';
|
||||
const PARENT_SESSION = process.argv[3] ?? '20260614_70';
|
||||
const USERNAME = 'john';
|
||||
const apiTarget = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
||||
const apiSecret = process.env.TKMIND_SERVER__SECRET_KEY ?? '';
|
||||
const h5ApiBase = (process.env.H5_PAGE_PATCH_BASE ?? 'http://127.0.0.1:8081').replace(/\/$/, '');
|
||||
const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
function isHttpsTarget(target) {
|
||||
return target.startsWith('https://');
|
||||
}
|
||||
|
||||
function createApiFetch() {
|
||||
return async (pathname, init = {}) => {
|
||||
const url = new URL(pathname, apiTarget);
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': apiSecret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return undiciFetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
dispatcher: isHttpsTarget(apiTarget) ? insecureDispatcher : undefined,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSessionReply(apiFetch, sessionId, requestId, prompt) {
|
||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw new Error(text || '无法建立事件流');
|
||||
}
|
||||
|
||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(text || 'reply 失败');
|
||||
}
|
||||
replyResponse.body?.cancel?.();
|
||||
|
||||
const reader = Readable.fromWeb(eventsResponse.body);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messages = [];
|
||||
|
||||
const pushMessage = (list, message) => {
|
||||
const idx = list.findIndex((item) => item.id === message.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...list];
|
||||
next[idx] = message;
|
||||
return next;
|
||||
}
|
||||
return [...list, message];
|
||||
};
|
||||
|
||||
const messageVisibleText = (message) =>
|
||||
(message?.content ?? [])
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
for await (const chunk of reader) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const routingId = event.chat_request_id ?? event.request_id;
|
||||
if (routingId && routingId !== requestId) continue;
|
||||
|
||||
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
|
||||
messages = pushMessage(messages, event.message);
|
||||
} else if (event.type === 'UpdateConversation') {
|
||||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
|
||||
} else if (event.type === 'Error') {
|
||||
throw new Error(event.error || 'goose 执行失败');
|
||||
} else if (event.type === 'Finish') {
|
||||
const assistantTexts = messages
|
||||
.filter((item) => item.role === 'assistant')
|
||||
.map((item) => messageVisibleText(item))
|
||||
.filter(Boolean);
|
||||
const combined = assistantTexts.join('\n\n');
|
||||
const withPatch = [...assistantTexts].reverse().find((text) => text.includes('mindspace-page-update'));
|
||||
return { text: withPatch ?? combined ?? messageVisibleText([...messages].reverse().find((item) => item.role === 'assistant')) };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('goose 回复未完成');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = createDbPool();
|
||||
const userAuth = createUserAuth(pool, { h5Root: root });
|
||||
const pageService = createPageService(pool, {
|
||||
h5Root: root,
|
||||
storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace'),
|
||||
});
|
||||
const pageLiveEdit = createPageLiveEditService({
|
||||
pageService,
|
||||
resolveUserIdForAgentSession: async (sessionId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
|
||||
[sessionId],
|
||||
);
|
||||
return rows[0]?.user_id ?? null;
|
||||
},
|
||||
});
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
encryptionKey:
|
||||
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? '',
|
||||
});
|
||||
const pageEditSession = createPageEditSessionService({
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
userAuth,
|
||||
pageService,
|
||||
pageLiveEdit,
|
||||
llmProviderService,
|
||||
});
|
||||
const apiFetch = createApiFetch();
|
||||
|
||||
const [userRows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
|
||||
USERNAME,
|
||||
]);
|
||||
const userId = userRows[0]?.id;
|
||||
if (!userId) throw new Error(`用户 ${USERNAME} 不存在`);
|
||||
|
||||
const pageBefore = await pageService.getPage(userId, PAGE_ID);
|
||||
console.log('目标页面:', pageBefore.title, `(v${pageBefore.versionNo})`);
|
||||
console.log('父 goose 会话:', PARENT_SESSION);
|
||||
|
||||
const forked = await pageEditSession.forkSession({
|
||||
userId,
|
||||
pageId: PAGE_ID,
|
||||
parentSessionId: PARENT_SESSION,
|
||||
h5ApiBase,
|
||||
});
|
||||
console.log('✓ 子会话已 fork:', forked.sessionId);
|
||||
|
||||
const contentPreview = String(pageBefore.content ?? '').slice(0, 12000);
|
||||
const prompt = `请基于下方【当前页面 HTML】全面升级为更精美的旅行主题落地页:
|
||||
- 深色沉浸式 Hero + 玻璃拟态卡片 + 渐变强调文字
|
||||
- 保留瑞士、马尔代夫、京都三个目的地
|
||||
- 必须包含 mindspace-cover meta 与完整内联 CSS(CSP 安全,禁止外部 JS)
|
||||
- 标题「探索世界 · TKMind」,summary 写一句精炼中文
|
||||
|
||||
**禁止**读写工作区文件。完成后**只**在回复末尾输出一个 \`\`\`mindspace-page-update\`\`\` 代码块(JSON:title、summary、content 完整 HTML)。
|
||||
|
||||
【当前页面 HTML】
|
||||
${contentPreview}`;
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
console.log('→ 向 goose 发送重生成指令…');
|
||||
const reply = await executeSessionReply(apiFetch, forked.sessionId, requestId, prompt);
|
||||
console.log('✓ goose 回复完成,文本长度:', reply.text.length);
|
||||
console.log(reply.text.slice(-1200));
|
||||
|
||||
let pageAfter = await pageService.getPage(userId, PAGE_ID);
|
||||
let changed =
|
||||
pageAfter.versionNo !== pageBefore.versionNo ||
|
||||
pageAfter.updatedAt !== pageBefore.updatedAt ||
|
||||
pageAfter.content !== pageBefore.content;
|
||||
|
||||
if (!changed) {
|
||||
const patch = extractMindSpacePagePatch(reply.text);
|
||||
if (patch) {
|
||||
console.log('→ 从 mindspace-page-update 块应用补丁…');
|
||||
await pageLiveEdit.applyAgentPatch({
|
||||
sessionId: forked.sessionId,
|
||||
pageId: PAGE_ID,
|
||||
...patch,
|
||||
changeNote: 'goose 重生成精美旅行页',
|
||||
});
|
||||
pageAfter = await pageService.getPage(userId, PAGE_ID);
|
||||
changed =
|
||||
pageAfter.versionNo !== pageBefore.versionNo ||
|
||||
pageAfter.updatedAt !== pageBefore.updatedAt ||
|
||||
pageAfter.content !== pageBefore.content;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n结果:');
|
||||
console.log(' 版本:', pageBefore.versionNo, '→', pageAfter.versionNo);
|
||||
console.log(' 摘要:', pageAfter.summary?.slice(0, 120));
|
||||
console.log(' 内容已变更:', changed ? '是' : '否');
|
||||
|
||||
const publicBase = (process.env.H5_PUBLIC_BASE_URL ?? 'https://g2.tkmind.cn').replace(/\/$/, '');
|
||||
const [pubRows] = await pool.query(
|
||||
`SELECT url_slug, status FROM h5_publish_records WHERE id = (SELECT current_publish_id FROM h5_page_records WHERE id = ?)`,
|
||||
[PAGE_ID],
|
||||
);
|
||||
const slug = pubRows[0]?.url_slug;
|
||||
if (slug) {
|
||||
console.log(' 公开预览:', `${publicBase}/p/${slug}`);
|
||||
}
|
||||
|
||||
await pageEditSession.closeSession({
|
||||
userId,
|
||||
sessionId: forked.sessionId,
|
||||
pageId: PAGE_ID,
|
||||
parentSessionId: PARENT_SESSION,
|
||||
summary: '已通过子会话重生成旅行主题页视觉。',
|
||||
});
|
||||
|
||||
await pool.end();
|
||||
if (!changed) {
|
||||
console.error('\n⚠ 页面内容未检测到变更,请检查 goose 是否调用了 mindspace_page_patch');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✅ 测试完成');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ fi
|
||||
export NODE_ENV=production
|
||||
export H5_PORT="${H5_PORT:-8081}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
@@ -16,7 +16,7 @@ export NODE_ENV=production
|
||||
PORTAL_PORT="${H5_PORT:-8081}"
|
||||
PLAZA_PORT="${PLAZA_PORT:-3001}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
@@ -23,96 +23,11 @@ function loadEnvFile(filePath) {
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const COVER_QUALITY = 'auto=format&fit=crop&w=1920&h=1200&q=90';
|
||||
|
||||
const CATEGORY_IMAGE_POOLS = {
|
||||
'work-report': [
|
||||
`https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1460925895917-afdab827c52f?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1522071820081-009f0129c71c?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1504384308090-c894fdcc538d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1542744173-8e7e53415bb0?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1600880292203-757bb62b4baf?${COVER_QUALITY}`,
|
||||
],
|
||||
'study-notes': [
|
||||
`https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1516116216624-53e697fedbea?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1515879214367-846362d3526?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1676299085922-0a9a504459f8?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1434030216411-0b793f4b4173?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1503676260728-1c00da094a0b?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1524995998267-8169c4e50d05?${COVER_QUALITY}`,
|
||||
],
|
||||
creative: [
|
||||
`https://images.unsplash.com/photo-1513364776144-60967b0f800f?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1514565131-fce0801e5785?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1511379938544-be09687a4d4b?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1541961017774-22349e4a1262?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1561998338-13ad7886534d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?${COVER_QUALITY}`,
|
||||
],
|
||||
business: [
|
||||
`https://images.unsplash.com/photo-1441986300917-64674bd600d8?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1518611012118-696072aa579a?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1554118811-1e0d58224f24?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1447933601403-0c6688de566e?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1560472354-b33ff0c44a43?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1414235077428-338989a2e8c0?${COVER_QUALITY}`,
|
||||
],
|
||||
travel: [
|
||||
`https://images.unsplash.com/photo-1506905925346-21bda4d32df4?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1596422846544-75c675fcd5d0?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1493976040374-85c8e577f47e?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1488646953014-85cb44e25828?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1476514525535-07fb3b4fcc5f?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1501785888041-af3ef285b470?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1528127269322-539801943592?${COVER_QUALITY}`,
|
||||
],
|
||||
'data-analysis': [
|
||||
`https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1543286386-713bdd548da4?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1555949963-aa79dcee981c?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1518186285589-2f7649de83e0?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1639322537504-6427a16b0a28?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1591696205602-2f950c417cb9?${COVER_QUALITY}`,
|
||||
],
|
||||
lifestyle: [
|
||||
`https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1497215842964-222b430dc094?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1455390582260-0446deae2f3f?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1416879595882-3373a0480b5b?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1498805903192-2f734adc632a?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1556911220-e15b29be8c8f?${COVER_QUALITY}`,
|
||||
],
|
||||
other: [
|
||||
`https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1484480974693-6ca0a7823cce?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1529156069898-49953e39b3ac?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1522202176988-66273c2fd55f?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1515378792601-1a587fd37b2d?${COVER_QUALITY}`,
|
||||
`https://images.unsplash.com/photo-1523240795612-9a054b0db644?${COVER_QUALITY}`,
|
||||
],
|
||||
};
|
||||
|
||||
const COVER_SPECS = [
|
||||
{
|
||||
match: 'Q2 产品增长复盘',
|
||||
file: 'work-report-growth.jpg',
|
||||
url: `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
|
||||
url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1280&h=800&q=85',
|
||||
},
|
||||
{
|
||||
match: 'AI 客服落地',
|
||||
@@ -222,36 +137,21 @@ const CATEGORY_FALLBACKS = {
|
||||
other: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1280&h=800&q=85',
|
||||
};
|
||||
|
||||
function hashTitle(title) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < title.length; i += 1) {
|
||||
hash = (hash * 31 + title.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function coverFileName(categorySlug, title) {
|
||||
const hash = hashTitle(title).toString(36);
|
||||
const asciiSlug = String(title)
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
const suffix = asciiSlug ? `${asciiSlug}-${hash}` : hash;
|
||||
return `${categorySlug}-${suffix}.jpg`;
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function resolveSpec(title, categorySlug) {
|
||||
const spec = COVER_SPECS.find((item) => title.includes(item.match));
|
||||
if (spec) return spec;
|
||||
const pool = CATEGORY_IMAGE_POOLS[categorySlug] ?? CATEGORY_IMAGE_POOLS.other;
|
||||
const url = pool[hashTitle(title) % pool.length];
|
||||
return {
|
||||
match: title,
|
||||
file: coverFileName(categorySlug, title),
|
||||
url,
|
||||
file: `${categorySlug}-${slugify(title)}.jpg`,
|
||||
url: CATEGORY_FALLBACKS[categorySlug] ?? CATEGORY_FALLBACKS.other,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,7 +208,7 @@ const [posts] = await pool.query(
|
||||
let updated = 0;
|
||||
for (const post of posts) {
|
||||
const spec = resolveSpec(post.title, post.category_slug);
|
||||
await downloadCover(spec, (CATEGORY_IMAGE_POOLS[post.category_slug] ?? CATEGORY_IMAGE_POOLS.other)[0]);
|
||||
await downloadCover(spec, CATEGORY_FALLBACKS[post.category_slug]);
|
||||
const coverUrl = `${coverBase}/${spec.file}`;
|
||||
if (post.cover_url === coverUrl) continue;
|
||||
await pool.query(`UPDATE plaza_posts SET cover_url = ?, updated_at = ? WHERE id = ?`, [
|
||||
|
||||
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool, initSchema } from '../db.mjs';
|
||||
import { createPlazaPostService } from '../plaza-posts.mjs';
|
||||
import { initializeDefaultSpace } from '../mindspace.mjs';
|
||||
import { EXTRA_DEMO_POSTS } from './plaza-demo-posts.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -26,7 +25,7 @@ function loadEnvFile(filePath) {
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const BASE_DEMO_POSTS = [
|
||||
const DEMO_POSTS = [
|
||||
{
|
||||
category: 'work-report',
|
||||
title: 'Q2 产品增长复盘:从 0 到 10 万 DAU',
|
||||
@@ -199,8 +198,6 @@ const BASE_DEMO_POSTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_POSTS = [...BASE_DEMO_POSTS, ...EXTRA_DEMO_POSTS];
|
||||
|
||||
async function loadUser(pool, username) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, username, slug, display_name FROM h5_users WHERE username = ? LIMIT 1`,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Map local test domains -> 127.0.0.1 for dev (macOS hosts + per-domain resolver).
|
||||
*
|
||||
* Usage:
|
||||
* sudo node scripts/setup-local-test-dns.mjs
|
||||
* sudo node scripts/setup-local-test-dns.mjs --remove
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { DNS_MARKER, DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs';
|
||||
|
||||
const remove = process.argv.includes('--remove');
|
||||
const hostsPath = '/etc/hosts';
|
||||
const resolverDir = '/etc/resolver';
|
||||
const resolverBody = `nameserver 127.0.0.1\nport ${DNS_PORT}\n# ${DNS_MARKER}\n`;
|
||||
|
||||
function flushDnsCache() {
|
||||
try {
|
||||
execSync('dscacheutil -flushcache', { stdio: 'ignore' });
|
||||
execSync('killall -HUP mDNSResponder', { stdio: 'ignore' });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function hostLine(host) {
|
||||
return `${LOCAL_IP} ${host} # ${DNS_MARKER}`;
|
||||
}
|
||||
|
||||
function removeHostLines(lines, host) {
|
||||
return lines.filter(
|
||||
(line) =>
|
||||
!line.includes(DNS_MARKER) &&
|
||||
!new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line),
|
||||
);
|
||||
}
|
||||
|
||||
function hostReady(lines, host) {
|
||||
return lines.some(
|
||||
(line) =>
|
||||
(line.includes(DNS_MARKER) && line.includes(host)) ||
|
||||
new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line),
|
||||
);
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
if (process.getuid?.() !== 0) {
|
||||
console.log('需要 root 权限,请执行:');
|
||||
console.log(` sudo node ${process.argv[1]} --remove`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let lines = fs.readFileSync(hostsPath, 'utf8').split('\n');
|
||||
for (const host of TEST_HOSTS) {
|
||||
lines = removeHostLines(lines, host);
|
||||
try {
|
||||
if (fs.existsSync(path.join(resolverDir, host))) fs.unlinkSync(path.join(resolverDir, host));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
execSync(`tee ${hostsPath}`, {
|
||||
input: `${lines.join('\n').replace(/\n?$/, '\n')}`,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
flushDnsCache();
|
||||
console.log(`已移除本地测试域名:${TEST_HOSTS.join(', ')}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const currentLines = fs.readFileSync(hostsPath, 'utf8').split('\n');
|
||||
const allReady = TEST_HOSTS.every((host) => {
|
||||
const resolverPath = path.join(resolverDir, host);
|
||||
const resolverReady =
|
||||
fs.existsSync(resolverPath) && fs.readFileSync(resolverPath, 'utf8').trim() === resolverBody.trim();
|
||||
return hostReady(currentLines, host) && resolverReady;
|
||||
});
|
||||
|
||||
if (allReady) {
|
||||
console.log(`本地测试域名已配置:${TEST_HOSTS.join(', ')}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.getuid?.() !== 0) {
|
||||
console.log('需要 root 权限写入 /etc/hosts 和 /etc/resolver,请执行:');
|
||||
console.log(` sudo pnpm setup:local-dns`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let lines = currentLines;
|
||||
for (const host of TEST_HOSTS) {
|
||||
if (!hostReady(lines, host)) {
|
||||
lines = lines.filter((line) => !line.includes(DNS_MARKER) || !line.includes(host));
|
||||
lines.push(hostLine(host));
|
||||
console.log(`已添加 hosts:${hostLine(host)}`);
|
||||
} else {
|
||||
console.log(`${host} 已在 /etc/hosts 中指向 ${LOCAL_IP}`);
|
||||
}
|
||||
}
|
||||
|
||||
execSync(`tee ${hostsPath}`, {
|
||||
input: `${lines.join('\n').replace(/\n?$/, '\n')}`,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
fs.mkdirSync(resolverDir, { recursive: true });
|
||||
for (const host of TEST_HOSTS) {
|
||||
const resolverPath = path.join(resolverDir, host);
|
||||
fs.writeFileSync(resolverPath, resolverBody, 'utf8');
|
||||
console.log(`已添加 resolver:${resolverPath} -> 127.0.0.1:${DNS_PORT}`);
|
||||
}
|
||||
|
||||
flushDnsCache();
|
||||
console.log('');
|
||||
console.log('下一步:');
|
||||
console.log(' sudo pnpm setup:local-tls # 生成本地 HTTPS 证书');
|
||||
console.log(' pnpm dev # 启动全栈');
|
||||
console.log(' sudo pnpm dev:local-proxy # HTTPS :443 统一入口');
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot local dev TLS for *.localhost (no sudo needed).
|
||||
*
|
||||
* Usage: pnpm setup:local-test
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
ADMIN_HOST,
|
||||
H5_HOST,
|
||||
H5_PUBLIC_BASE,
|
||||
OPS_HOST,
|
||||
PLAZA_HOST,
|
||||
PLAZA_PUBLIC_BASE,
|
||||
publicUrl,
|
||||
USES_LOCALHOST,
|
||||
} from './local-test-config.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
if (!USES_LOCALHOST) {
|
||||
if (process.getuid?.() !== 0) {
|
||||
console.log('非 *.localhost 域名需要 root 配置 DNS,请执行:');
|
||||
console.log(' sudo pnpm setup:local-test');
|
||||
process.exit(1);
|
||||
}
|
||||
const dns = spawnSync('node', [path.join(root, 'scripts/setup-local-test-dns.mjs')], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (dns.status !== 0) process.exit(dns.status ?? 1);
|
||||
}
|
||||
|
||||
const tls = spawnSync('node', [path.join(root, 'scripts/local-test-tls.mjs')], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (tls.status !== 0) process.exit(tls.status ?? 1);
|
||||
|
||||
console.log('');
|
||||
console.log('本地开发环境已配置。启动:');
|
||||
console.log(' pnpm dev');
|
||||
console.log(' pnpm dev:local-proxy # 若 dev 未自动拉起 HTTPS 代理');
|
||||
console.log('');
|
||||
console.log('访问:');
|
||||
console.log(` MindSpace H5 ${H5_PUBLIC_BASE}/?preview=mindspace`);
|
||||
console.log(` memind_adm ${publicUrl(ADMIN_HOST)}`);
|
||||
console.log(` Plaza ${PLAZA_PUBLIC_BASE}/plaza`);
|
||||
console.log(` Ops 审核后台 ${publicUrl(OPS_HOST)}/ops/`);
|
||||
@@ -21,22 +21,6 @@ const plazaPublicBase = (
|
||||
).replace(/\/$/, '');
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const skipBuild = process.argv.includes('--skip-build');
|
||||
const noPortal = process.argv.includes('--no-portal');
|
||||
|
||||
function resolveNpmBin() {
|
||||
const candidates = [
|
||||
process.env.NPM_BIN,
|
||||
'/opt/homebrew/opt/node@22/bin/npm',
|
||||
'/opt/homebrew/bin/npm',
|
||||
'/usr/local/bin/npm',
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
const npmBin = resolveNpmBin();
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
@@ -143,9 +127,6 @@ freePort(plazaPort);
|
||||
|
||||
try {
|
||||
if (!(await portOpen(portalPort))) {
|
||||
if (noPortal) {
|
||||
throw new Error(`Portal 未运行 (${portalUrl}),请先启动 cn.tkmind.memind-portal`);
|
||||
}
|
||||
console.log(`==> 启动 Portal @ ${portalUrl}`);
|
||||
portal = spawnChild('node', ['server.mjs'], 'portal');
|
||||
await waitFor(() => portOpen(portalPort), 'Portal');
|
||||
@@ -155,14 +136,14 @@ try {
|
||||
|
||||
if (!skipBuild) {
|
||||
console.log('==> 构建 Plaza Next.js(生产)…');
|
||||
execSync(`"${npmBin}" run build`, { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv }, shell: true });
|
||||
execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } });
|
||||
} else {
|
||||
console.log('==> 跳过 next build(--skip-build)');
|
||||
}
|
||||
|
||||
console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`);
|
||||
plaza = spawnChild(
|
||||
npmBin,
|
||||
'npm',
|
||||
['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
|
||||
'plaza',
|
||||
plazaDir,
|
||||
|
||||
@@ -36,9 +36,11 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "=== 校验 ==="
|
||||
for f in server.mjs user-publish.mjs user-space.mjs mindspace-page-edit-session.mjs package.json; do
|
||||
local_sum="$(md5 -q "${ROOT}/${f}" 2>/dev/null || md5sum "${ROOT}/${f}" | awk '{print $1}')"
|
||||
remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' | awk '{print \$1}'")"
|
||||
for f in server.mjs user-publish.mjs user-space.mjs mindspace-sandbox-mcp.mjs mindspace-page-edit-session.mjs package.json; do
|
||||
local_sum="$(openssl md5 "${ROOT}/${f}" 2>/dev/null | awk '{print $2}' \
|
||||
|| md5 -q "${ROOT}/${f}" 2>/dev/null \
|
||||
|| md5sum "${ROOT}/${f}" 2>/dev/null | awk '{print $1}')"
|
||||
remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' 2>/dev/null | awk '{print \$1}'")"
|
||||
if [[ "${local_sum}" == "${remote_sum}" ]]; then
|
||||
echo "✓ ${f}"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user