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:
Your Name
2026-06-17 16:39:39 -07:00
parent ab0718938e
commit b0f5d6a51c
98 changed files with 5394 additions and 3010 deletions
+46 -8
View File
@@ -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/
+56
View File
@@ -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*
+95
View File
@@ -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
View File
@@ -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) {
+2 -14
View File
@@ -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);
+130
View File
@@ -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
View File
@@ -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
+83
View File
@@ -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));
}
+108
View File
@@ -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));
});
}
+168
View File
@@ -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));
});
}
+65
View File
@@ -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();
}
+5
View File
@@ -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
+12
View File
@@ -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}`);
}
+1 -225
View File
@@ -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);
}
+286
View File
@@ -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);
});
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8 -108
View File
@@ -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 = ?`, [
+1 -4
View File
@@ -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`,
+120
View File
@@ -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 统一入口');
+51
View File
@@ -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/`);
+2 -21
View File
@@ -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,
+5 -3
View File
@@ -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