Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search), MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
|
||||
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.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
function spawnChild(command, args, label) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) return;
|
||||
if (code && code !== 0) {
|
||||
console.error(`[${label}] exited with code ${code}`);
|
||||
shutdown(code ?? 1);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
let server;
|
||||
let vite;
|
||||
let stopping = false;
|
||||
|
||||
function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
server?.kill('SIGTERM');
|
||||
vite?.kill('SIGTERM');
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0));
|
||||
process.on('SIGTERM', () => shutdown(0));
|
||||
|
||||
async function waitForPortal(retries = 40) {
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
try {
|
||||
const res = await fetch(`${portalUrl}/auth/status`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// portal still starting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Portal 未在 ${portalUrl} 启动,请检查 MySQL 配置与 ${portalPort} 端口`);
|
||||
}
|
||||
|
||||
console.log('==> 启动 portal (server.mjs)...');
|
||||
server = spawnChild('node', ['server.mjs'], 'portal');
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
console.log(`==> Portal 就绪: ${portalUrl}`);
|
||||
console.log('==> 启动 Vite @ http://127.0.0.1:5173');
|
||||
console.log('==> 我的空间 UI 预览: http://127.0.0.1:5173/?preview=mindspace');
|
||||
vite = spawnChild('npx', ['vite'], 'vite');
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
shutdown(1);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* john 账号水果主题 E2E(仅本地):工作区 HTML → 预览图 → 保存到空间 → 可点击链接
|
||||
* 用法:node scripts/fruit-theme-john-e2e.mjs
|
||||
* 环境:自动启动本地 server(MINDSPACE_E2E_PORT);勿对生产 URL 跑本脚本
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
import { buildPublicUrl } from '../user-publish.mjs';
|
||||
import {
|
||||
ensureWorkspaceHtmlThumbnail,
|
||||
workspaceThumbnailRelativePath,
|
||||
} from '../mindspace-workspace-thumbnails.mjs';
|
||||
import { isModernFeedThumbnail } from '../mindspace-thumbnails.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
if (/go\.tkmind\.cn|goo\.tkmind\.cn|120\.26\.184\.105/i.test(process.env.BASE_URL ?? '')) {
|
||||
throw new Error('本脚本仅用于本地验证,请勿设置生产 BASE_URL');
|
||||
}
|
||||
|
||||
const portalPort = Number(process.env.MINDSPACE_E2E_PORT ?? 18140);
|
||||
const BASE_URL = `http://127.0.0.1:${portalPort}`;
|
||||
const HTML_NAME = 'fruit-theme-test.html';
|
||||
const USERNAME = 'john';
|
||||
const JOHN_PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj';
|
||||
const publishDir = path.join(root, 'MindSpace', USERNAME);
|
||||
const htmlPath = path.join(publishDir, HTML_NAME);
|
||||
const thumbRel = workspaceThumbnailRelativePath(HTML_NAME);
|
||||
const publicPageUrl = buildPublicUrl(BASE_URL, USERNAME, HTML_NAME);
|
||||
const publicThumbUrl = buildPublicUrl(BASE_URL, USERNAME, thumbRel);
|
||||
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
|
||||
|
||||
const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
H5_PUBLIC_BASE_URL: BASE_URL,
|
||||
TKMIND_API_TARGET: 'http://127.0.0.1:9',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let portalLogs = '';
|
||||
portal.stdout.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
portal.stderr.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
|
||||
async function waitForPortal() {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (portal.exitCode != null) throw new Error(`本地 server 提前退出\n${portalLogs}`);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/auth/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// still starting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error(`本地 server 启动超时\n${portalLogs}`);
|
||||
}
|
||||
|
||||
function cookieHeader(setCookie) {
|
||||
if (!setCookie) return '';
|
||||
const parts = Array.isArray(setCookie) ? setCookie : [setCookie];
|
||||
return parts.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function request(pathname, { method = 'GET', body, cookie } = {}) {
|
||||
const headers = {};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
const response = await fetch(`${BASE_URL}${pathname}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const type = response.headers.get('content-type') ?? '';
|
||||
const payload = type.includes('json') ? await response.json() : await response.text();
|
||||
return { response, payload, setCookie: response.headers.getSetCookie?.() ?? response.headers.get('set-cookie') };
|
||||
}
|
||||
|
||||
async function ensureJohnPassword() {
|
||||
const pool = createDbPool();
|
||||
const auth = createUserAuth(pool, { h5Root: root });
|
||||
let login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
|
||||
if (!login.ok) {
|
||||
const [rows] = await pool.query(`SELECT id, email FROM h5_users WHERE username = ? LIMIT 1`, [
|
||||
USERNAME,
|
||||
]);
|
||||
const user = rows[0];
|
||||
if (!user) throw new Error('本地数据库无 john 用户,请先注册或导入数据');
|
||||
const email = user.email ?? `${USERNAME}@local.test`;
|
||||
if (!user.email) {
|
||||
await pool.query(`UPDATE h5_users SET email = ? WHERE id = ?`, [email, user.id]);
|
||||
}
|
||||
const reset = await auth.resetPassword({ username: USERNAME, email, password: JOHN_PASSWORD });
|
||||
assert.equal(reset.ok, true, reset.message ?? '重置 john 本地密码失败');
|
||||
login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
|
||||
}
|
||||
assert.equal(login.ok, true, login.error ?? 'john 本地登录失败');
|
||||
const { response, setCookie } = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: USERNAME, password: JOHN_PASSWORD },
|
||||
});
|
||||
assert.equal(response.status, 200, 'john HTTP 登录应成功');
|
||||
await pool.end();
|
||||
return cookieHeader(setCookie);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== john 水果主题 E2E(本地) ===');
|
||||
await waitForPortal();
|
||||
console.log(`BASE_URL: ${BASE_URL}`);
|
||||
|
||||
const html = await fs.readFile(htmlPath, 'utf8');
|
||||
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
|
||||
|
||||
const svg = await ensureWorkspaceHtmlThumbnail(publishDir, HTML_NAME, html, {
|
||||
title: '夏日鲜果指南',
|
||||
subtitle: '水果主题测试',
|
||||
});
|
||||
assert.equal(isModernFeedThumbnail(svg), true, '工作区预览图应为新版 3:4');
|
||||
assert.match(svg, /美食|鲜果|FRUIT/i, '预览图应体现水果/美食主题');
|
||||
const hasPhoto = /preserveAspectRatio="xMidYMid slice"/.test(svg);
|
||||
console.log(`✓ 工作区预览图已生成: ${thumbRel}(含主图: ${hasPhoto ? '是' : '否'})`);
|
||||
|
||||
const pageCheck = await request(`/MindSpace/${USERNAME}/${HTML_NAME}`);
|
||||
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
|
||||
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
|
||||
|
||||
const thumbCheck = await request(`/MindSpace/${USERNAME}/${thumbRel}`);
|
||||
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
|
||||
assert.match(String(thumbCheck.payload), /width="540" height="720"/, '线上预览图为 3:4');
|
||||
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
|
||||
|
||||
assert.match(agentReplyLink, /^\[.+\]\(https?:\/\/.+\)$/);
|
||||
console.log(`✓ Agent 应回复的可点击链接:\n ${agentReplyLink}`);
|
||||
|
||||
const cookie = await ensureJohnPassword();
|
||||
console.log('✓ john 本地登录成功');
|
||||
|
||||
const { response: spaceRes, payload: spacePayload } = await request('/api/mindspace/v1/space', {
|
||||
cookie,
|
||||
});
|
||||
assert.equal(spaceRes.status, 200, JSON.stringify(spacePayload));
|
||||
|
||||
const saveBody = {
|
||||
title: '夏日鲜果指南 · 水果主题测试',
|
||||
summary: 'TKMind 水果主题预览图 E2E',
|
||||
content: html,
|
||||
content_format: 'html',
|
||||
page_type: 'html',
|
||||
category_code: 'draft',
|
||||
};
|
||||
const created = await request('/api/mindspace/v1/pages', {
|
||||
method: 'POST',
|
||||
body: saveBody,
|
||||
cookie,
|
||||
});
|
||||
assert.equal(created.response.status, 201, JSON.stringify(created.payload));
|
||||
const pageId = created.payload?.data?.id ?? created.payload?.data?.page?.id;
|
||||
assert.ok(pageId, '应创建页面记录');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
const thumbApi = await request(`/api/mindspace/v1/pages/${pageId}/thumbnail`, { cookie });
|
||||
assert.equal(thumbApi.response.status, 200, '页面缩略图 API 应返回 200');
|
||||
assert.match(String(thumbApi.payload), /width="540" height="720"/);
|
||||
console.log(`✓ 我的空间缩略图 API 200: /api/mindspace/v1/pages/${pageId}/thumbnail`);
|
||||
|
||||
console.log('\n=== 全部通过 ===');
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} finally {
|
||||
portal.kill('SIGTERM');
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { plazaOpenApiExtensions } from './plaza-openapi.mjs';
|
||||
|
||||
const OUTPUT_PATH = path.join(import.meta.dirname, '..', 'openapi.json');
|
||||
const plazaOpenApi = plazaOpenApiExtensions();
|
||||
|
||||
const jsonContent = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'MindSpace H5 API',
|
||||
version: '0.1.0',
|
||||
description: 'Generated OpenAPI contract for MindSpace H5, Plaza, and public publication routes.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/api', description: 'Authenticated H5 API' },
|
||||
{ url: '/', description: 'Public publication and homepage routes' },
|
||||
],
|
||||
tags: [
|
||||
{ name: 'Auth' },
|
||||
{ name: 'Space' },
|
||||
{ name: 'Uploads' },
|
||||
{ name: 'Assets' },
|
||||
{ name: 'Pages' },
|
||||
{ name: 'Publications' },
|
||||
{ name: 'Agent Jobs' },
|
||||
{ name: 'Public Web' },
|
||||
...plazaOpenApi.tags,
|
||||
],
|
||||
paths: {
|
||||
'/auth/register': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register a user and initialize a MindSpace workspace',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/RegisterRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Registration succeeded',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/RegisterResponseEnvelope' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/auth/login': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login with username and password',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/LoginRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Authenticated session',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/AuthStatus' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/auth/logout': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout current user session',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Logged out',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/auth/status': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Inspect current auth/session status',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current auth status',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/AuthStatus' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/space': {
|
||||
get: {
|
||||
tags: ['Space'],
|
||||
summary: 'Get current MindSpace summary, quota, and categories',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current space',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/DataEnvelope' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/space/quota': {
|
||||
get: {
|
||||
tags: ['Space'],
|
||||
summary: 'Get current quota state',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Quota state',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/space/categories': {
|
||||
get: {
|
||||
tags: ['Space'],
|
||||
summary: 'List current space categories',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Category list',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/space/cleanup': {
|
||||
get: {
|
||||
tags: ['Space'],
|
||||
summary: 'Preview cleanup candidates',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Cleanup candidates',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Space'],
|
||||
summary: 'Delete selected cleanup candidates',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/CleanupRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Cleanup result',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/uploads': {
|
||||
post: {
|
||||
tags: ['Uploads'],
|
||||
summary: 'Create an upload session',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/CreateUploadRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Upload session created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/uploads/{uploadId}/content': {
|
||||
put: {
|
||||
tags: ['Uploads'],
|
||||
summary: 'Upload raw file bytes',
|
||||
parameters: [{ $ref: '#/components/parameters/UploadId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/octet-stream': {
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Bytes written',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/uploads/{uploadId}/complete': {
|
||||
post: {
|
||||
tags: ['Uploads'],
|
||||
summary: 'Complete an upload and materialize the asset',
|
||||
parameters: [{ $ref: '#/components/parameters/UploadId' }],
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Asset created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/uploads/{uploadId}': {
|
||||
delete: {
|
||||
tags: ['Uploads'],
|
||||
summary: 'Cancel upload session',
|
||||
parameters: [{ $ref: '#/components/parameters/UploadId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Upload cancelled',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/assets': {
|
||||
get: {
|
||||
tags: ['Assets'],
|
||||
summary: 'List assets',
|
||||
parameters: [
|
||||
{ name: 'category_id', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'category_code', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Asset list',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/assets/{assetId}/download': {
|
||||
get: {
|
||||
tags: ['Assets'],
|
||||
summary: 'Download asset bytes',
|
||||
parameters: [{ $ref: '#/components/parameters/AssetId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Asset content',
|
||||
content: { '*/*': { schema: { type: 'string', format: 'binary' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/assets/{assetId}/thumbnail': {
|
||||
get: {
|
||||
tags: ['Assets'],
|
||||
summary: 'Render asset thumbnail',
|
||||
parameters: [{ $ref: '#/components/parameters/AssetId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'SVG thumbnail',
|
||||
content: { 'image/svg+xml': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/assets/{assetId}/preview': {
|
||||
get: {
|
||||
tags: ['Assets'],
|
||||
summary: 'Render asset preview HTML',
|
||||
parameters: [{ $ref: '#/components/parameters/AssetId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Preview HTML',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/assets/{assetId}': {
|
||||
delete: {
|
||||
tags: ['Assets'],
|
||||
summary: 'Delete asset',
|
||||
parameters: [{ $ref: '#/components/parameters/AssetId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Delete result',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/analyze-chat-save': {
|
||||
post: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Analyze whether an assistant message should be saved as page or asset',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/AnalyzeChatSaveRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Chat save analysis',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/save-from-chat': {
|
||||
post: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Save assistant message into MindSpace',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/SaveFromChatRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Saved page or asset',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/from-asset': {
|
||||
post: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Convert an owned asset into a draft page',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/PageFromAssetRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Draft page created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages': {
|
||||
get: {
|
||||
tags: ['Pages'],
|
||||
summary: 'List pages',
|
||||
parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Page list',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Create a draft page',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/CreatePageRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Page created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}': {
|
||||
get: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Get page detail and versions',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Page detail',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Create a new page version',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdatePageRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Updated page',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}/thumbnail': {
|
||||
get: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Render page thumbnail',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'SVG thumbnail',
|
||||
content: { 'image/svg+xml': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}/preview': {
|
||||
get: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Render safe page preview HTML',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Preview HTML',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}/publish-check': {
|
||||
post: {
|
||||
tags: ['Publications'],
|
||||
summary: 'Check whether page can be published',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/PublishCheckRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Publish check result',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}/redacted-copy': {
|
||||
post: {
|
||||
tags: ['Pages'],
|
||||
summary: 'Create a redacted copy of the current page version',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/RedactedCopyRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Redacted page created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/pages/{pageId}/publish': {
|
||||
post: {
|
||||
tags: ['Publications'],
|
||||
summary: 'Publish the current page version',
|
||||
parameters: [{ $ref: '#/components/parameters/PageId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/PublishPageRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Publication created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/publications/{publicationId}/offline': {
|
||||
post: {
|
||||
tags: ['Publications'],
|
||||
summary: 'Offline an online publication',
|
||||
parameters: [{ $ref: '#/components/parameters/PublicationId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Publication offlined',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/publications/{publicationId}/stats': {
|
||||
get: {
|
||||
tags: ['Publications'],
|
||||
summary: 'Get publication statistics',
|
||||
parameters: [{ $ref: '#/components/parameters/PublicationId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Publication stats',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/agent/jobs': {
|
||||
get: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'List current user agent jobs',
|
||||
parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Job list',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Create an agent job bound to owned assets',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateAgentJobRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: 'Job created',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/agent/jobs/{jobId}': {
|
||||
get: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Get agent job detail',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Job detail',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/agent/jobs/{jobId}/cancel': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Cancel agent job',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Cancelled job',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/agent/jobs/{jobId}/retry': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Retry agent job',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Retried job',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mindspace/v1/agent/jobs/{jobId}/run': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Start queued agent job execution',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
responses: {
|
||||
202: {
|
||||
description: 'Execution started',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/internal/agent/jobs/{jobId}/claim': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Internal worker claims a queued job',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Claimed job payload',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/internal/agent/jobs/{jobId}/assets/{assetId}': {
|
||||
get: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Internal worker downloads an authorized job asset',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }, { $ref: '#/components/parameters/AssetId' }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Asset content',
|
||||
content: { '*/*': { schema: { type: 'string', format: 'binary' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/internal/agent/jobs/{jobId}/heartbeat': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Internal worker posts heartbeat/progress updates',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/HeartbeatRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Progress updated',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/internal/agent/jobs/{jobId}/complete': {
|
||||
post: {
|
||||
tags: ['Agent Jobs'],
|
||||
summary: 'Internal worker completes or fails a job',
|
||||
parameters: [{ $ref: '#/components/parameters/JobId' }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/CompleteJobRequest' } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Final job state',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/u/{ownerSlug}': {
|
||||
get: {
|
||||
tags: ['Public Web'],
|
||||
summary: 'Render public homepage for a user slug',
|
||||
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Public homepage HTML',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/u/{ownerSlug}/pages/{urlSlug}': {
|
||||
get: {
|
||||
tags: ['Public Web'],
|
||||
summary: 'Render a published page via long public route',
|
||||
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }, { $ref: '#/components/parameters/UrlSlug' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Published page HTML',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Public Web'],
|
||||
summary: 'Submit password gate for a protected page',
|
||||
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }, { $ref: '#/components/parameters/UrlSlug' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['password'],
|
||||
properties: { password: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Published page HTML or password gate',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/s/{token}': {
|
||||
get: {
|
||||
tags: ['Public Web'],
|
||||
summary: 'Render a private-link publication',
|
||||
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Published page HTML',
|
||||
content: { 'text/html': { schema: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...plazaOpenApi.paths,
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
UploadId: { name: 'uploadId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
AssetId: { name: 'assetId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
PageId: { name: 'pageId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
PublicationId: { name: 'publicationId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
JobId: { name: 'jobId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
OwnerSlug: { name: 'ownerSlug', in: 'path', required: true, schema: { type: 'string' } },
|
||||
UrlSlug: { name: 'urlSlug', in: 'path', required: true, schema: { type: 'string' } },
|
||||
...plazaOpenApi.parameters,
|
||||
},
|
||||
schemas: {
|
||||
SuccessEnvelope: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
DataEnvelope: {
|
||||
type: 'object',
|
||||
required: ['data', 'request_id'],
|
||||
properties: {
|
||||
data: { type: 'object', additionalProperties: true },
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
ListEnvelope: {
|
||||
type: 'object',
|
||||
required: ['data', 'request_id'],
|
||||
properties: {
|
||||
data: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
page: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
next_cursor: { type: ['string', 'null'] },
|
||||
has_more: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
AuthStatus: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
RegisterRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password', 'displayName', 'email'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
RegisterResponseEnvelope: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
user: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
CleanupRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
item_ids: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
CreateUploadRequest: {
|
||||
type: 'object',
|
||||
required: ['category_id', 'filename', 'size_bytes'],
|
||||
properties: {
|
||||
category_id: { type: 'string' },
|
||||
filename: { type: 'string' },
|
||||
size_bytes: { type: 'integer' },
|
||||
declared_mime_type: { type: 'string' },
|
||||
},
|
||||
},
|
||||
AnalyzeChatSaveRequest: {
|
||||
type: 'object',
|
||||
required: ['session_id', 'message_id'],
|
||||
properties: {
|
||||
session_id: { type: 'string' },
|
||||
message_id: { type: 'string' },
|
||||
selected_link_index: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
SaveFromChatRequest: {
|
||||
type: 'object',
|
||||
required: ['session_id', 'message_id'],
|
||||
properties: {
|
||||
session_id: { type: 'string' },
|
||||
message_id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
template_id: { type: 'string' },
|
||||
category_code: { type: 'string' },
|
||||
page_type: { type: 'string' },
|
||||
selected_link_index: { type: 'integer' },
|
||||
acknowledged_finding_ids: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
PageFromAssetRequest: {
|
||||
type: 'object',
|
||||
required: ['asset_id'],
|
||||
properties: {
|
||||
asset_id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
template_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
CreatePageRequest: {
|
||||
type: 'object',
|
||||
required: ['title', 'content'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
content_format: { type: 'string', enum: ['markdown', 'html'] },
|
||||
template_id: { type: 'string' },
|
||||
page_type: { type: 'string' },
|
||||
category_code: { type: 'string' },
|
||||
},
|
||||
},
|
||||
UpdatePageRequest: {
|
||||
type: 'object',
|
||||
required: ['expected_version', 'title', 'content'],
|
||||
properties: {
|
||||
expected_version: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
template_id: { type: 'string' },
|
||||
page_type: { type: 'string' },
|
||||
change_note: { type: 'string' },
|
||||
},
|
||||
},
|
||||
PublishCheckRequest: {
|
||||
type: 'object',
|
||||
required: ['page_version_id', 'access_mode', 'url_slug'],
|
||||
properties: {
|
||||
page_version_id: { type: 'string' },
|
||||
access_mode: { type: 'string' },
|
||||
url_slug: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
expires_at: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
RedactedCopyRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page_version_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
PublishPageRequest: {
|
||||
type: 'object',
|
||||
required: ['page_version_id', 'access_mode', 'url_slug'],
|
||||
properties: {
|
||||
page_version_id: { type: 'string' },
|
||||
access_mode: { type: 'string' },
|
||||
url_slug: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
expires_at: { type: 'integer' },
|
||||
acknowledged_finding_ids: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
CreateAgentJobRequest: {
|
||||
type: 'object',
|
||||
required: ['job_type', 'instruction', 'allowed_asset_ids'],
|
||||
properties: {
|
||||
job_type: { type: 'string' },
|
||||
instruction: { type: 'string' },
|
||||
allowed_asset_ids: { type: 'array', items: { type: 'string' } },
|
||||
output_category_id: { type: 'string' },
|
||||
output_type: { type: 'string' },
|
||||
idempotency_key: { type: 'string' },
|
||||
locale: { type: 'string' },
|
||||
timezone: { type: 'string' },
|
||||
capabilities: { type: 'object', additionalProperties: { type: 'boolean' } },
|
||||
},
|
||||
},
|
||||
HeartbeatRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stage: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
CompleteJobRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
error_code: { type: 'string' },
|
||||
error_message: { type: 'string' },
|
||||
output_type: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
content_format: { type: 'string' },
|
||||
page_type: { type: 'string' },
|
||||
template_id: { type: 'string' },
|
||||
source_asset_ids: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
details: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
...plazaOpenApi.schemas,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const rendered = `${JSON.stringify(jsonContent, null, 2)}\n`;
|
||||
if (process.argv.includes('--print')) {
|
||||
process.stdout.write(rendered);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(OUTPUT_PATH, rendered);
|
||||
process.stdout.write(`Generated ${OUTPUT_PATH}\n`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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 separator = trimmed.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadH5Environment(scriptDirectory) {
|
||||
loadEnvFile(path.join(scriptDirectory, '../../../.env.local'));
|
||||
loadEnvFile(path.join(scriptDirectory, '../.env'));
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const fakePort = Number(process.env.MINDSPACE_AGENT_FAKE_PORT ?? 18161);
|
||||
const portalPort = Number(process.env.MINDSPACE_AGENT_PORT ?? 18162);
|
||||
const baseUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const storageRoot = path.join('/tmp', `mindspace-agent-jobs-${suffix}`);
|
||||
const users = [
|
||||
{
|
||||
username: `msaj_owner_${suffix}`,
|
||||
email: `msaj-owner-${suffix}@example.test`,
|
||||
password: 'MindSpace-Agent-Owner-2026',
|
||||
},
|
||||
];
|
||||
|
||||
const eventStreams = new Map();
|
||||
const fakeSessions = new Map();
|
||||
|
||||
function sseWrite(res, data) {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
const fakeUpstream = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
||||
const bodyChunks = [];
|
||||
for await (const chunk of req) {
|
||||
bodyChunks.push(chunk);
|
||||
}
|
||||
const raw = bodyChunks.length ? Buffer.concat(bodyChunks).toString('utf8') : '';
|
||||
const body = raw ? JSON.parse(raw) : null;
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/agent/start') {
|
||||
const sessionId = `agent-job-session-${suffix}`;
|
||||
fakeSessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
working_dir: body?.working_dir ?? '/tmp',
|
||||
extensions: body?.extension_overrides ?? [],
|
||||
});
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ id: sessionId, name: 'Agent Job E2E Session' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
||||
if (req.method === 'GET' && sessionMatch) {
|
||||
const session = fakeSessions.get(sessionMatch[1]);
|
||||
if (!session) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ message: 'session not found' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: session.id,
|
||||
working_dir: session.working_dir,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionMatch = url.pathname.match(/^\/sessions\/([^/]+)\/extensions$/);
|
||||
if (req.method === 'GET' && extensionMatch) {
|
||||
const session = fakeSessions.get(extensionMatch[1]);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ extensions: session?.extensions ?? [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
[
|
||||
'/agent/update_working_dir',
|
||||
'/agent/update_session',
|
||||
'/agent/remove_extension',
|
||||
'/agent/add_extension',
|
||||
'/agent/restart',
|
||||
'/agent/harness_remember',
|
||||
'/agent/harness_bootstrap',
|
||||
].includes(url.pathname)
|
||||
) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
const eventMatch = url.pathname.match(/^\/sessions\/([^/]+)\/events$/);
|
||||
if (req.method === 'GET' && eventMatch) {
|
||||
const sessionId = eventMatch[1];
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
sseWrite(res, { type: 'Ping' });
|
||||
eventStreams.set(sessionId, res);
|
||||
req.on('close', () => {
|
||||
if (eventStreams.get(sessionId) === res) {
|
||||
eventStreams.delete(sessionId);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const replyMatch = url.pathname.match(/^\/sessions\/([^/]+)\/reply$/);
|
||||
if (req.method === 'POST' && replyMatch) {
|
||||
const sessionId = replyMatch[1];
|
||||
const stream = eventStreams.get(sessionId);
|
||||
if (!stream) {
|
||||
res.statusCode = 409;
|
||||
res.end(JSON.stringify({ message: 'events stream not connected' }));
|
||||
return;
|
||||
}
|
||||
const requestId = body?.request_id;
|
||||
const prompt = String(body?.user_message?.content?.[0]?.text ?? '');
|
||||
const content = {
|
||||
title: prompt.includes('weekly.md') ? 'Agent 生成周报' : 'Agent 页面',
|
||||
summary: '根据授权资料生成的草稿',
|
||||
content: '# Agent 周报\n\n本周已完成资产分析,并生成了草稿页面。',
|
||||
content_format: 'markdown',
|
||||
};
|
||||
setTimeout(() => {
|
||||
sseWrite(stream, {
|
||||
type: 'Message',
|
||||
request_id: requestId,
|
||||
message: {
|
||||
id: `assistant-${suffix}`,
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
content: [{ type: 'text', text: JSON.stringify(content) }],
|
||||
},
|
||||
token_state: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 20,
|
||||
totalTokens: 32,
|
||||
accumulatedInputTokens: 12,
|
||||
accumulatedOutputTokens: 20,
|
||||
accumulatedTotalTokens: 32,
|
||||
},
|
||||
});
|
||||
sseWrite(stream, {
|
||||
type: 'Finish',
|
||||
request_id: requestId,
|
||||
reason: 'completed',
|
||||
token_state: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 20,
|
||||
totalTokens: 32,
|
||||
accumulatedInputTokens: 12,
|
||||
accumulatedOutputTokens: 20,
|
||||
accumulatedTotalTokens: 32,
|
||||
},
|
||||
});
|
||||
stream.end();
|
||||
eventStreams.delete(sessionId);
|
||||
}, 50);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ message: 'not found' }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => fakeUpstream.listen(fakePort, '127.0.0.1', resolve));
|
||||
|
||||
const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: path.join(import.meta.dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
TKMIND_API_TARGET: `http://127.0.0.1:${fakePort}`,
|
||||
MINDSPACE_STORAGE_ROOT: storageRoot,
|
||||
MINDSPACE_AGENT_JOBS_ENABLED: 'true',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let portalLogs = '';
|
||||
portal.stdout.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
portal.stderr.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
|
||||
async function waitForPortal() {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// waiting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Portal 启动超时\n${portalLogs}`);
|
||||
}
|
||||
|
||||
async function request(pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, options);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const body = contentType.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function registerAndLogin(user) {
|
||||
const registration = await request('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...user, displayName: user.username }),
|
||||
});
|
||||
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
|
||||
|
||||
const login = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user.username, password: user.password }),
|
||||
});
|
||||
assert.equal(login.response.status, 200, JSON.stringify(login.body));
|
||||
|
||||
return {
|
||||
id: registration.body.user.id,
|
||||
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(cookie) {
|
||||
return { Cookie: cookie, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function pollJob(cookie, jobId) {
|
||||
let lastJob = null;
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const detail = await request(`/api/mindspace/v1/agent/jobs/${jobId}`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(detail.response.status, 200, JSON.stringify(detail.body));
|
||||
const job = detail.body.data;
|
||||
lastJob = job;
|
||||
if (['completed', 'failed', 'cancelled', 'timed_out'].includes(job.status)) {
|
||||
return job;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`等待 Agent job 完成超时: ${JSON.stringify(lastJob)}`);
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
const owner = await registerAndLogin(users[0]);
|
||||
assert.ok(owner.cookie);
|
||||
|
||||
const space = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(space.response.status, 200, JSON.stringify(space.body));
|
||||
const oaCategory = space.body.data.categories.find((category) => category.code === 'oa');
|
||||
assert.ok(oaCategory);
|
||||
|
||||
const source = Buffer.from('# Weekly Notes\n\n- Revenue up\n- Risk controlled\n', 'utf8');
|
||||
const createdUpload = await request('/api/mindspace/v1/uploads', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
category_id: oaCategory.id,
|
||||
filename: 'weekly.md',
|
||||
size_bytes: source.length,
|
||||
declared_mime_type: 'text/markdown',
|
||||
}),
|
||||
});
|
||||
assert.equal(createdUpload.response.status, 201, JSON.stringify(createdUpload.body));
|
||||
|
||||
const uploadedContent = await request(createdUpload.body.data.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: source,
|
||||
});
|
||||
assert.equal(uploadedContent.response.status, 200, JSON.stringify(uploadedContent.body));
|
||||
|
||||
const completedUpload = await request(
|
||||
`/api/mindspace/v1/uploads/${createdUpload.body.data.id}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
assert.equal(completedUpload.response.status, 201, JSON.stringify(completedUpload.body));
|
||||
const assetId = completedUpload.body.data.id;
|
||||
|
||||
const createdJob = await request('/api/mindspace/v1/agent/jobs', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
job_type: 'generate_page',
|
||||
instruction: '请根据周报资料生成一个中文周报草稿页面',
|
||||
allowed_asset_ids: [assetId],
|
||||
output_type: 'page_draft',
|
||||
}),
|
||||
});
|
||||
assert.equal(createdJob.response.status, 201, JSON.stringify(createdJob.body));
|
||||
const jobId = createdJob.body.data.id;
|
||||
assert.equal(createdJob.body.data.status, 'queued');
|
||||
|
||||
const started = await request(`/api/mindspace/v1/agent/jobs/${jobId}/run`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: '{}',
|
||||
});
|
||||
assert.equal(started.response.status, 202, JSON.stringify(started.body));
|
||||
|
||||
const finalJob = await pollJob(owner.cookie, jobId);
|
||||
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
|
||||
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
|
||||
|
||||
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(pageDetail.response.status, 200, JSON.stringify(pageDetail.body));
|
||||
assert.equal(pageDetail.body.data.title, 'Agent 生成周报');
|
||||
assert.match(pageDetail.body.data.content, /Agent 周报/);
|
||||
|
||||
const draftPages = await request('/api/mindspace/v1/pages?status=draft', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(draftPages.response.status, 200, JSON.stringify(draftPages.body));
|
||||
assert.equal(draftPages.body.data.length, 1);
|
||||
|
||||
const [usageRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS count FROM h5_usage_records WHERE user_id = ?`,
|
||||
[owner.id],
|
||||
);
|
||||
assert.equal(Number(usageRows[0]?.count ?? 0), 1, 'Agent 运行后应记录一次扣费');
|
||||
|
||||
console.log('MindSpace agent jobs E2E passed');
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM h5_users WHERE username IN (?)`, [[users[0].username]]);
|
||||
await pool.end();
|
||||
fakeUpstream.close();
|
||||
portal.kill('SIGTERM');
|
||||
await new Promise((resolve) => portal.once('exit', resolve));
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const portalPort = Number(process.env.MINDSPACE_E2E_PORT ?? 18090);
|
||||
const baseUrl = process.env.MINDSPACE_E2E_URL ?? `http://127.0.0.1:${portalPort}`;
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const storageRoot = path.join('/tmp', `mindspace-assets-${suffix}`);
|
||||
const portal = process.env.MINDSPACE_E2E_URL
|
||||
? null
|
||||
: spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: path.join(import.meta.dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
TKMIND_API_TARGET: 'http://127.0.0.1:9',
|
||||
MINDSPACE_STORAGE_ROOT: storageRoot,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let portalLogs = '';
|
||||
portal?.stdout.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
portal?.stderr.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
const users = [
|
||||
{
|
||||
username: `ms_owner_${suffix}`,
|
||||
email: `ms-owner-${suffix}@example.test`,
|
||||
password: 'MindSpace-E2E-Owner-2026',
|
||||
},
|
||||
{
|
||||
username: `ms_other_${suffix}`,
|
||||
email: `ms-other-${suffix}@example.test`,
|
||||
password: 'MindSpace-E2E-Other-2026',
|
||||
},
|
||||
];
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, options);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const body = contentType.includes('application/json')
|
||||
? await response.json()
|
||||
: Buffer.from(await response.arrayBuffer());
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function waitForPortal() {
|
||||
if (!portal) return;
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Portal is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Portal 启动超时\n${portalLogs}`);
|
||||
}
|
||||
|
||||
async function registerAndLogin(user) {
|
||||
const registration = await request('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
displayName: user.username,
|
||||
}),
|
||||
});
|
||||
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
|
||||
|
||||
const login = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user.username, password: user.password }),
|
||||
});
|
||||
assert.equal(login.response.status, 200, JSON.stringify(login.body));
|
||||
const cookie = login.response.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
assert.ok(cookie, '登录响应应设置会话 Cookie');
|
||||
return { id: registration.body.user.id, cookie };
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
let owner;
|
||||
let other;
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
owner = await registerAndLogin(users[0]);
|
||||
other = await registerAndLogin(users[1]);
|
||||
|
||||
const initialSpace = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(initialSpace.response.status, 200, JSON.stringify(initialSpace.body));
|
||||
const oaCategory = initialSpace.body.data.categories.find((category) => category.code === 'oa');
|
||||
assert.ok(oaCategory, '默认空间应包含 OA 工作区');
|
||||
|
||||
const content = Buffer.from('MindSpace upload E2E\n', 'utf8');
|
||||
const created = await request('/api/mindspace/v1/uploads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
category_id: oaCategory.id,
|
||||
filename: 'e2e-notes.txt',
|
||||
size_bytes: content.length,
|
||||
declared_mime_type: 'text/plain',
|
||||
}),
|
||||
});
|
||||
assert.equal(created.response.status, 201, JSON.stringify(created.body));
|
||||
|
||||
const contentUpload = await request(created.body.data.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
assert.equal(contentUpload.response.status, 200, JSON.stringify(contentUpload.body));
|
||||
|
||||
const completed = await request(
|
||||
`/api/mindspace/v1/uploads/${created.body.data.id}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
assert.equal(completed.response.status, 201, JSON.stringify(completed.body));
|
||||
const assetId = completed.body.data.id;
|
||||
|
||||
const assets = await request('/api/mindspace/v1/assets?category_code=oa', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(assets.response.status, 200, JSON.stringify(assets.body));
|
||||
assert.equal(assets.body.data.length, 1);
|
||||
assert.equal(assets.body.data[0].id, assetId);
|
||||
|
||||
const downloaded = await request(`/api/mindspace/v1/assets/${assetId}/download`, {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(downloaded.response.status, 200);
|
||||
assert.deepEqual(downloaded.body, content);
|
||||
|
||||
const ownerSpaceAfterUpload = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(ownerSpaceAfterUpload.body.data.quota.usedBytes, content.length);
|
||||
assert.equal(ownerSpaceAfterUpload.body.data.quota.reservedBytes, 0);
|
||||
assert.equal(
|
||||
ownerSpaceAfterUpload.body.data.categories.find((category) => category.code === 'oa')
|
||||
.itemCount,
|
||||
1,
|
||||
);
|
||||
|
||||
const foreignAssets = await request('/api/mindspace/v1/assets?category_code=oa', {
|
||||
headers: { Cookie: other.cookie },
|
||||
});
|
||||
assert.equal(foreignAssets.response.status, 200);
|
||||
assert.equal(foreignAssets.body.data.length, 0);
|
||||
|
||||
const foreignDownload = await request(`/api/mindspace/v1/assets/${assetId}/download`, {
|
||||
headers: { Cookie: other.cookie },
|
||||
});
|
||||
assert.equal(foreignDownload.response.status, 404);
|
||||
|
||||
const foreignDelete = await request(`/api/mindspace/v1/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: other.cookie },
|
||||
});
|
||||
assert.equal(foreignDelete.response.status, 404);
|
||||
|
||||
const deleted = await request(`/api/mindspace/v1/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(deleted.response.status, 200, JSON.stringify(deleted.body));
|
||||
|
||||
const ownerSpaceAfterDelete = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(ownerSpaceAfterDelete.body.data.quota.usedBytes, 0);
|
||||
assert.equal(
|
||||
ownerSpaceAfterDelete.body.data.categories.find((category) => category.code === 'oa')
|
||||
.itemCount,
|
||||
0,
|
||||
);
|
||||
|
||||
console.log('MindSpace API E2E passed');
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
|
||||
users[0].username,
|
||||
users[1].username,
|
||||
]);
|
||||
await pool.end();
|
||||
if (portal) {
|
||||
portal.kill('SIGTERM');
|
||||
await new Promise((resolve) => portal.once('exit', resolve));
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const fakePort = Number(process.env.MINDSPACE_PAGE_FAKE_PORT ?? 18121);
|
||||
const portalPort = Number(process.env.MINDSPACE_PAGE_PORT ?? 18122);
|
||||
const baseUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const storageRoot = path.join('/tmp', `mindspace-pages-${suffix}`);
|
||||
const sessionId = `page-e2e-session-${suffix}`;
|
||||
const messageId = `assistant-message-${suffix}`;
|
||||
const sourceContent = '# 安全页面\n\n这是来自 AI 会话的正文。\n\n<script>alert("blocked")</script>';
|
||||
const users = [
|
||||
{
|
||||
username: `msp_owner_${suffix}`,
|
||||
email: `msp-owner-${suffix}@example.test`,
|
||||
password: 'MindSpace-Page-Owner-2026',
|
||||
},
|
||||
{
|
||||
username: `msp_other_${suffix}`,
|
||||
email: `msp-other-${suffix}@example.test`,
|
||||
password: 'MindSpace-Page-Other-2026',
|
||||
},
|
||||
];
|
||||
|
||||
const fakeUpstream = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === `/sessions/${sessionId}`) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: sessionId,
|
||||
name: '页面 E2E 会话',
|
||||
conversation: [
|
||||
{
|
||||
id: messageId,
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
content: [{ type: 'text', text: sourceContent }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ message: 'not found' }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => fakeUpstream.listen(fakePort, '127.0.0.1', resolve));
|
||||
|
||||
const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: path.join(import.meta.dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
TKMIND_API_TARGET: `http://127.0.0.1:${fakePort}`,
|
||||
MINDSPACE_STORAGE_ROOT: storageRoot,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let portalLogs = '';
|
||||
portal.stdout.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
portal.stderr.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
|
||||
async function waitForPortal() {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Portal is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Portal 启动超时\n${portalLogs}`);
|
||||
}
|
||||
|
||||
async function request(pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, options);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const body = contentType.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function registerAndLogin(user) {
|
||||
const registration = await request('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...user, displayName: user.username }),
|
||||
});
|
||||
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
|
||||
const login = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user.username, password: user.password }),
|
||||
});
|
||||
assert.equal(login.response.status, 200, JSON.stringify(login.body));
|
||||
return {
|
||||
id: registration.body.user.id,
|
||||
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
|
||||
};
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
const owner = await registerAndLogin(users[0]);
|
||||
const other = await registerAndLogin(users[1]);
|
||||
assert.ok(owner.cookie && other.cookie);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO h5_user_sessions (agent_session_id, user_id, created_at) VALUES (?, ?, ?)`,
|
||||
[sessionId, owner.id, Date.now()],
|
||||
);
|
||||
|
||||
const created = await request('/api/mindspace/v1/pages/save-from-chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
title: 'AI 会话页面',
|
||||
summary: '由会话生成的安全页面',
|
||||
template_id: 'report',
|
||||
}),
|
||||
});
|
||||
assert.equal(created.response.status, 201, JSON.stringify(created.body));
|
||||
const pageId = created.body.data.page.id;
|
||||
assert.equal(created.body.data.kind, 'page');
|
||||
assert.equal(created.body.data.page.versionNo, 1);
|
||||
assert.equal(created.body.data.page.content, sourceContent);
|
||||
|
||||
const detail = await request(`/api/mindspace/v1/pages/${pageId}`, {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(detail.response.status, 200);
|
||||
assert.equal(detail.body.data.sourceSessionId, sessionId);
|
||||
assert.equal(detail.body.data.versions.length, 1);
|
||||
|
||||
const preview = await request(`/api/mindspace/v1/pages/${pageId}/preview`, {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(preview.response.status, 200);
|
||||
assert.match(preview.body, /default-src 'none'/);
|
||||
assert.doesNotMatch(preview.body, /<script>alert/);
|
||||
assert.match(preview.body, /<script>/);
|
||||
|
||||
const updatedContent = `${sourceContent}\n\n第二版补充内容。`;
|
||||
const updated = await request(`/api/mindspace/v1/pages/${pageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_version: 1,
|
||||
title: 'AI 会话页面 v2',
|
||||
summary: '第二版摘要',
|
||||
content: updatedContent,
|
||||
template_id: 'knowledge-card',
|
||||
change_note: 'E2E 第二版',
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.response.status, 200, JSON.stringify(updated.body));
|
||||
assert.equal(updated.body.data.versionNo, 2);
|
||||
|
||||
const staleUpdate = await request(`/api/mindspace/v1/pages/${pageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_version: 1,
|
||||
title: '过期修改',
|
||||
content: '不应写入',
|
||||
template_id: 'editorial',
|
||||
}),
|
||||
});
|
||||
assert.equal(staleUpdate.response.status, 409);
|
||||
assert.equal(staleUpdate.body.error.code, 'version_conflict');
|
||||
|
||||
for (const pathname of [
|
||||
`/api/mindspace/v1/pages/${pageId}`,
|
||||
`/api/mindspace/v1/pages/${pageId}/preview`,
|
||||
]) {
|
||||
const foreignRead = await request(pathname, { headers: { Cookie: other.cookie } });
|
||||
assert.equal(foreignRead.response.status, 404);
|
||||
}
|
||||
|
||||
const foreignUpdate = await request(`/api/mindspace/v1/pages/${pageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Cookie: other.cookie,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_version: 2,
|
||||
title: '越权修改',
|
||||
content: '禁止',
|
||||
template_id: 'editorial',
|
||||
}),
|
||||
});
|
||||
assert.equal(foreignUpdate.response.status, 404);
|
||||
|
||||
const space = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(space.response.status, 200);
|
||||
assert.equal(
|
||||
space.body.data.categories.find((category) => category.code === 'draft').itemCount,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
space.body.data.quota.usedBytes,
|
||||
Buffer.byteLength(sourceContent) + Buffer.byteLength(updatedContent),
|
||||
);
|
||||
|
||||
console.log('MindSpace page API E2E passed');
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
|
||||
users[0].username,
|
||||
users[1].username,
|
||||
]);
|
||||
await pool.end();
|
||||
portal.kill('SIGTERM');
|
||||
await new Promise((resolve) => portal.once('exit', resolve));
|
||||
await new Promise((resolve) => fakeUpstream.close(resolve));
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const portalPort = Number(process.env.MINDSPACE_PUBLICATION_PORT ?? 18142);
|
||||
const baseUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const storageRoot = path.join('/tmp', `mindspace-publications-${suffix}`);
|
||||
const users = [
|
||||
{
|
||||
username: `msp_pub_owner_${suffix}`,
|
||||
email: `msp-pub-owner-${suffix}@example.test`,
|
||||
password: 'MindSpace-Publication-Owner-2026',
|
||||
},
|
||||
{
|
||||
username: `msp_pub_other_${suffix}`,
|
||||
email: `msp-pub-other-${suffix}@example.test`,
|
||||
password: 'MindSpace-Publication-Other-2026',
|
||||
},
|
||||
];
|
||||
|
||||
const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: path.join(import.meta.dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
TKMIND_API_TARGET: 'http://127.0.0.1:9',
|
||||
MINDSPACE_STORAGE_ROOT: storageRoot,
|
||||
MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let portalLogs = '';
|
||||
portal.stdout.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
portal.stderr.on('data', (chunk) => {
|
||||
portalLogs += chunk.toString();
|
||||
});
|
||||
|
||||
async function waitForPortal() {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Portal is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Portal 启动超时\n${portalLogs}`);
|
||||
}
|
||||
|
||||
async function request(pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, options);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const body = contentType.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function registerAndLogin(user) {
|
||||
const registration = await request('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...user, displayName: user.username }),
|
||||
});
|
||||
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
|
||||
const login = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user.username, password: user.password }),
|
||||
});
|
||||
assert.equal(login.response.status, 200, JSON.stringify(login.body));
|
||||
return {
|
||||
id: registration.body.user.id,
|
||||
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(cookie) {
|
||||
return { Cookie: cookie, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
const owner = await registerAndLogin(users[0]);
|
||||
const other = await registerAndLogin(users[1]);
|
||||
|
||||
const created = await request('/api/mindspace/v1/pages', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
title: '发布隔离测试',
|
||||
summary: '第一版摘要',
|
||||
content: '# 第一版\n\n这是线上应该保持的 V1 内容。',
|
||||
template_id: 'editorial',
|
||||
}),
|
||||
});
|
||||
assert.equal(created.response.status, 201, JSON.stringify(created.body));
|
||||
const page = created.body.data;
|
||||
|
||||
const checked = await request(`/api/mindspace/v1/pages/${page.id}/publish-check`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: page.currentVersionId,
|
||||
access_mode: 'public',
|
||||
url_slug: 'immutable-page',
|
||||
}),
|
||||
});
|
||||
assert.equal(checked.response.status, 200, JSON.stringify(checked.body));
|
||||
assert.equal(checked.body.data.allowed, true);
|
||||
|
||||
const published = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: page.currentVersionId,
|
||||
access_mode: 'public',
|
||||
url_slug: 'immutable-page',
|
||||
acknowledged_finding_ids: [],
|
||||
}),
|
||||
});
|
||||
assert.equal(published.response.status, 201, JSON.stringify(published.body));
|
||||
const publicUrl = published.body.data.publicUrl;
|
||||
|
||||
const publicV1 = await request(publicUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Mobile',
|
||||
Referer: 'https://example.com/campaign/private-path',
|
||||
},
|
||||
});
|
||||
assert.equal(publicV1.response.status, 200);
|
||||
assert.match(publicV1.body, /V1 内容/);
|
||||
|
||||
const usageAfterPublish = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(usageAfterPublish.body.data.quota.publicPageUsed, 1);
|
||||
assert.equal(usageAfterPublish.body.data.quota.monthlyViewUsed, 1);
|
||||
assert.equal(
|
||||
usageAfterPublish.body.data.categories.find((category) => category.code === 'public')
|
||||
.itemCount,
|
||||
1,
|
||||
);
|
||||
|
||||
const overLimitPage = await request('/api/mindspace/v1/pages', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
title: '第二个公开页面',
|
||||
content: '这个页面应被套餐数量限制阻止。',
|
||||
template_id: 'editorial',
|
||||
}),
|
||||
});
|
||||
const overLimit = await request(
|
||||
`/api/mindspace/v1/pages/${overLimitPage.body.data.id}/publish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: overLimitPage.body.data.currentVersionId,
|
||||
access_mode: 'public',
|
||||
url_slug: 'over-limit-page',
|
||||
acknowledged_finding_ids: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(overLimit.response.status, 429);
|
||||
assert.equal(overLimit.body.error.code, 'public_page_limit_exceeded');
|
||||
|
||||
const updated = await request(`/api/mindspace/v1/pages/${page.id}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
expected_version: 1,
|
||||
title: '发布隔离测试 v2',
|
||||
summary: '第二版摘要',
|
||||
content: '# 第二版\n\n这是尚未重新发布的 V2 内容。',
|
||||
template_id: 'report',
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.response.status, 200, JSON.stringify(updated.body));
|
||||
|
||||
const stillV1 = await request(publicUrl);
|
||||
assert.equal(stillV1.response.status, 200);
|
||||
assert.match(stillV1.body, /V1 内容/);
|
||||
assert.doesNotMatch(stillV1.body, /V2 内容/);
|
||||
|
||||
const republished = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: updated.body.data.currentVersionId,
|
||||
access_mode: 'owner_only',
|
||||
url_slug: 'immutable-page',
|
||||
acknowledged_finding_ids: [],
|
||||
}),
|
||||
});
|
||||
assert.equal(republished.response.status, 201, JSON.stringify(republished.body));
|
||||
|
||||
const anonymousProtected = await request(publicUrl);
|
||||
assert.equal(anonymousProtected.response.status, 404);
|
||||
const foreignProtected = await request(publicUrl, { headers: { Cookie: other.cookie } });
|
||||
assert.equal(foreignProtected.response.status, 404);
|
||||
const ownerProtected = await request(publicUrl, { headers: { Cookie: owner.cookie } });
|
||||
assert.equal(ownerProtected.response.status, 200);
|
||||
assert.match(ownerProtected.body, /V2 内容/);
|
||||
|
||||
const publishMode = async (accessMode, extra = {}) => {
|
||||
const response = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: updated.body.data.currentVersionId,
|
||||
access_mode: accessMode,
|
||||
url_slug: 'immutable-page',
|
||||
acknowledged_finding_ids: [],
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
assert.equal(response.response.status, 201, JSON.stringify(response.body));
|
||||
return response.body.data;
|
||||
};
|
||||
|
||||
const loginRequired = await publishMode('login_required');
|
||||
const anonymousLoginRequired = await request(publicUrl);
|
||||
assert.equal(anonymousLoginRequired.response.status, 401);
|
||||
const loggedInVisitor = await request(publicUrl, { headers: { Cookie: other.cookie } });
|
||||
assert.equal(loggedInVisitor.response.status, 200);
|
||||
assert.match(loggedInVisitor.body, /V2 内容/);
|
||||
|
||||
const accessPassword = 'Protected-Page-2026';
|
||||
const passwordProtected = await publishMode('password', { password: accessPassword });
|
||||
const passwordGate = await request(publicUrl);
|
||||
assert.equal(passwordGate.response.status, 200);
|
||||
assert.match(passwordGate.body, /此页面受密码保护/);
|
||||
assert.doesNotMatch(passwordGate.body, /V2 内容/);
|
||||
const wrongPassword = await request(publicUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ password: 'wrong-password' }),
|
||||
});
|
||||
assert.equal(wrongPassword.response.status, 403);
|
||||
const correctPassword = await request(publicUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ password: accessPassword }),
|
||||
});
|
||||
assert.equal(correctPassword.response.status, 200);
|
||||
assert.match(correctPassword.body, /V2 内容/);
|
||||
|
||||
const privateLink = await publishMode('private_link');
|
||||
assert.match(privateLink.publicUrl, /^\/s\/[A-Za-z0-9_-]+$/);
|
||||
const privateLinkPage = await request(privateLink.publicUrl);
|
||||
assert.equal(privateLinkPage.response.status, 200);
|
||||
assert.match(privateLinkPage.body, /V2 内容/);
|
||||
const hiddenLongRoute = await request(publicUrl);
|
||||
assert.equal(hiddenLongRoute.response.status, 404);
|
||||
|
||||
const timeLimited = await publishMode('time_limited', {
|
||||
expires_at: Date.now() + 60_000,
|
||||
});
|
||||
const beforeExpiry = await request(publicUrl);
|
||||
assert.equal(beforeExpiry.response.status, 200);
|
||||
await pool.query(`UPDATE h5_publish_records SET expires_at = ? WHERE id = ?`, [
|
||||
Date.now() - 1,
|
||||
timeLimited.id,
|
||||
]);
|
||||
const afterExpiry = await request(publicUrl);
|
||||
assert.equal(afterExpiry.response.status, 404);
|
||||
|
||||
const finalPublication = await publishMode('public');
|
||||
const finalView = await request(publicUrl, {
|
||||
headers: {
|
||||
Cookie: owner.cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
},
|
||||
});
|
||||
assert.equal(finalView.response.status, 200);
|
||||
const stats = await request(
|
||||
`/api/mindspace/v1/publications/${finalPublication.id}/stats`,
|
||||
{ headers: { Cookie: owner.cookie } },
|
||||
);
|
||||
assert.equal(stats.response.status, 200);
|
||||
assert.equal(stats.body.data.totalViews, 1);
|
||||
assert.deepEqual(stats.body.data.devices, [{ deviceType: 'desktop', views: 1 }]);
|
||||
assert.deepEqual(stats.body.data.sources, [{ source: 'direct', views: 1 }]);
|
||||
const publicHomepage = await request(`/u/${users[0].username}`);
|
||||
assert.equal(publicHomepage.response.status, 200);
|
||||
assert.match(publicHomepage.body, /当前公开发布且在线的 MindSpace 页面/);
|
||||
assert.match(publicHomepage.body, /发布隔离测试 v2/);
|
||||
assert.match(publicHomepage.body, /1 个公开页面/);
|
||||
const foreignStats = await request(
|
||||
`/api/mindspace/v1/publications/${finalPublication.id}/stats`,
|
||||
{ headers: { Cookie: other.cookie } },
|
||||
);
|
||||
assert.equal(foreignStats.response.status, 404);
|
||||
|
||||
const risky = await request('/api/mindspace/v1/pages', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
title: '高风险页面',
|
||||
content: '身份证号码 310101199001011234',
|
||||
template_id: 'editorial',
|
||||
}),
|
||||
});
|
||||
const blocked = await request(`/api/mindspace/v1/pages/${risky.body.data.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: JSON.stringify({
|
||||
page_version_id: risky.body.data.currentVersionId,
|
||||
access_mode: 'public',
|
||||
url_slug: 'blocked-page',
|
||||
acknowledged_finding_ids: [],
|
||||
}),
|
||||
});
|
||||
assert.equal(blocked.response.status, 422);
|
||||
assert.equal(blocked.body.error.code, 'security_risk_blocked');
|
||||
|
||||
const foreignOffline = await request(
|
||||
`/api/mindspace/v1/publications/${finalPublication.id}/offline`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: authHeaders(other.cookie),
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
assert.equal(foreignOffline.response.status, 404);
|
||||
|
||||
const offlined = await request(
|
||||
`/api/mindspace/v1/publications/${finalPublication.id}/offline`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: authHeaders(owner.cookie),
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
assert.equal(offlined.response.status, 200);
|
||||
const afterOffline = await request(publicUrl, { headers: { Cookie: owner.cookie } });
|
||||
assert.equal(afterOffline.response.status, 404);
|
||||
const usageAfterOffline = await request('/api/mindspace/v1/space', {
|
||||
headers: { Cookie: owner.cookie },
|
||||
});
|
||||
assert.equal(usageAfterOffline.body.data.quota.publicPageUsed, 0);
|
||||
|
||||
const [events] = await pool.query(
|
||||
`SELECT event_type FROM h5_publication_events
|
||||
WHERE publish_id IN (
|
||||
SELECT id FROM h5_publish_records WHERE page_id = ?
|
||||
) ORDER BY created_at`,
|
||||
[page.id],
|
||||
);
|
||||
assert.equal(events[0].event_type, 'published');
|
||||
assert.equal(events.at(-1).event_type, 'offlined');
|
||||
assert.equal(
|
||||
events.filter((event) => event.event_type === 'republished').length,
|
||||
6,
|
||||
);
|
||||
|
||||
console.log('MindSpace publication API E2E passed');
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
|
||||
users[0].username,
|
||||
users[1].username,
|
||||
]);
|
||||
await pool.end();
|
||||
portal.kill('SIGTERM');
|
||||
await new Promise((resolve) => portal.once('exit', resolve));
|
||||
await new Promise((resolve) => portalLogs.includes('TKMind H5') ? resolve() : setTimeout(resolve, 10));
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import {
|
||||
ensureHtmlThumbnail,
|
||||
pageThumbnailKey,
|
||||
assetThumbnailKey,
|
||||
} from '../mindspace-thumbnails.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const storageRoot = process.env.MINDSPACE_STORAGE_ROOT
|
||||
? path.resolve(process.env.MINDSPACE_STORAGE_ROOT)
|
||||
: path.join(root, 'data', 'mindspace');
|
||||
|
||||
async function rebuildPageThumbnails(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id AS page_id, p.user_id, p.title, p.summary, p.page_type, pv.version_no, av.storage_key
|
||||
FROM h5_page_records p
|
||||
JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||||
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
|
||||
WHERE p.status <> 'deleted' AND p.page_type = 'html'`,
|
||||
);
|
||||
let rebuilt = 0;
|
||||
for (const row of rows) {
|
||||
const content = await fs.readFile(path.join(storageRoot, row.storage_key), 'utf8');
|
||||
await ensureHtmlThumbnail(storageRoot, pageThumbnailKey(row.user_id, row.page_id), content, {
|
||||
title: row.title,
|
||||
subtitle: row.summary || `草稿 v${row.version_no}`,
|
||||
tag: '网页',
|
||||
contentStorageKey: row.storage_key,
|
||||
});
|
||||
rebuilt += 1;
|
||||
}
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
async function rebuildAssetThumbnails(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id AS asset_id, a.user_id, a.display_name, c.category_code, av.storage_key
|
||||
FROM h5_assets a
|
||||
JOIN h5_space_categories c ON c.id = a.category_id
|
||||
JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1
|
||||
WHERE a.status <> 'deleted' AND a.mime_type = 'text/html'`,
|
||||
);
|
||||
let rebuilt = 0;
|
||||
for (const row of rows) {
|
||||
const content = await fs.readFile(path.join(storageRoot, row.storage_key), 'utf8');
|
||||
await ensureHtmlThumbnail(storageRoot, assetThumbnailKey(row.user_id, row.asset_id), content, {
|
||||
title: row.display_name,
|
||||
subtitle: String(row.category_code ?? 'HTML').toUpperCase(),
|
||||
contentStorageKey: row.storage_key,
|
||||
});
|
||||
rebuilt += 1;
|
||||
}
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
try {
|
||||
const pages = await rebuildPageThumbnails(pool);
|
||||
const assets = await rebuildAssetThumbnails(pool);
|
||||
console.log(`已重建缩略图:${pages} 个页面,${assets} 个 HTML 资产`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
export function plazaOpenApiExtensions() {
|
||||
const dataResponse = (description) => ({
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/DataEnvelope' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorResponse = (description) => ({
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tags: [{ name: 'Plaza' }, { name: 'Plaza Ops' }],
|
||||
paths: {
|
||||
'/plaza/v1/feed': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Get plaza feed (hot or new)',
|
||||
parameters: [
|
||||
{ name: 'sort', in: 'query', schema: { type: 'string', enum: ['hot', 'new'] } },
|
||||
{ name: 'category', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 50 } },
|
||||
],
|
||||
responses: { 200: dataResponse('Feed page') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/categories': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'List plaza categories',
|
||||
responses: { 200: dataResponse('Category list') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts/{postId}': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Get published plaza post',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
responses: {
|
||||
200: dataResponse('Post detail'),
|
||||
404: errorResponse('Post not found or hidden'),
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Update own plaza post metadata',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/UpdatePlazaPostRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Updated post') },
|
||||
},
|
||||
delete: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Hide own plaza post',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
responses: { 200: dataResponse('Hidden post') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Publish an online MindSpace publication to plaza',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/CreatePlazaPostRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: dataResponse('Created plaza post'),
|
||||
409: errorResponse('Already published to plaza'),
|
||||
},
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts/{postId}/reactions': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Add like, collect, or share reaction',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/PlazaReactionRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Reaction applied') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts/{postId}/reactions/{type}': {
|
||||
delete: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Remove like or collect reaction',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/PostId' },
|
||||
{ $ref: '#/components/parameters/ReactionType' },
|
||||
],
|
||||
responses: { 200: dataResponse('Reaction removed') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts/{postId}/comments': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'List post comments',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/PostId' },
|
||||
{ name: 'parent_id', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: { 200: dataResponse('Comment page') },
|
||||
},
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Create comment or reply',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/CreatePlazaCommentRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 201: dataResponse('Created comment') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/comments/{commentId}': {
|
||||
delete: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Soft-delete own comment',
|
||||
parameters: [{ $ref: '#/components/parameters/CommentId' }],
|
||||
responses: { 200: dataResponse('Deleted comment') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/comments/{commentId}/reactions': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Like or unlike a comment',
|
||||
parameters: [{ $ref: '#/components/parameters/CommentId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['liked'],
|
||||
properties: { liked: { type: 'boolean' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Comment reaction') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/posts/{postId}/reports': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Report a plaza post',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/PlazaReportRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 201: dataResponse('Report created') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/comments/{commentId}/reports': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Report a plaza comment',
|
||||
parameters: [{ $ref: '#/components/parameters/CommentId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/PlazaReportRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 201: dataResponse('Report created') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/users/{slug}': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Get plaza user profile',
|
||||
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
|
||||
responses: { 200: dataResponse('User profile') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/users/{slug}/posts': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'List user published plaza posts',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/UserSlug' },
|
||||
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: { 200: dataResponse('User posts') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/users/{slug}/follow': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Follow user',
|
||||
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
|
||||
responses: { 200: dataResponse('Following') },
|
||||
},
|
||||
delete: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Unfollow user',
|
||||
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
|
||||
responses: { 200: dataResponse('Unfollowed') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/seo/sitemap': {
|
||||
get: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Sitemap data for Next.js',
|
||||
responses: { 200: dataResponse('Sitemap entries') },
|
||||
},
|
||||
},
|
||||
'/plaza/v1/attribution/events': {
|
||||
post: {
|
||||
tags: ['Plaza'],
|
||||
summary: 'Record UTM attribution event',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/PlazaAttributionRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 201: dataResponse('Attribution recorded') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/review/queue': {
|
||||
get: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'List posts in review queue',
|
||||
parameters: [
|
||||
{ name: 'status', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: { 200: dataResponse('Review queue') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/review/posts/{postId}': {
|
||||
post: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Review a single plaza post',
|
||||
parameters: [{ $ref: '#/components/parameters/PostId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/OpsReviewRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Review result') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/review/batch': {
|
||||
post: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Batch review plaza posts (editor+)',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/OpsBatchReviewRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Batch review result') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/reports': {
|
||||
get: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'List pending reports',
|
||||
responses: { 200: dataResponse('Reports') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/reports/{reportId}/process': {
|
||||
post: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Process a report',
|
||||
parameters: [{ $ref: '#/components/parameters/ReportId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/OpsProcessReportRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Processed report') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/featured': {
|
||||
get: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'List featured placements',
|
||||
responses: { 200: dataResponse('Featured list') },
|
||||
},
|
||||
post: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Set featured placement',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/OpsFeaturedRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 201: dataResponse('Featured created') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/featured/{featuredId}': {
|
||||
delete: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Remove featured placement',
|
||||
parameters: [{ $ref: '#/components/parameters/FeaturedId' }],
|
||||
responses: { 200: dataResponse('Featured removed') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/analytics/overview': {
|
||||
get: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Plaza analytics overview',
|
||||
responses: { 200: dataResponse('Analytics overview') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/creators': {
|
||||
get: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'List plaza creators',
|
||||
parameters: [{ name: 'keyword', in: 'query', schema: { type: 'string' } }],
|
||||
responses: { 200: dataResponse('Creators') },
|
||||
},
|
||||
},
|
||||
'/ops/v1/creators/{userId}': {
|
||||
patch: {
|
||||
tags: ['Plaza Ops'],
|
||||
summary: 'Update creator moderation flags',
|
||||
parameters: [{ $ref: '#/components/parameters/OpsUserId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/OpsCreatorPatchRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: dataResponse('Creator updated') },
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
PostId: { name: 'postId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
CommentId: { name: 'commentId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
UserSlug: { name: 'slug', in: 'path', required: true, schema: { type: 'string' } },
|
||||
ReactionType: {
|
||||
name: 'type',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string', enum: ['like', 'collect'] },
|
||||
},
|
||||
ReportId: { name: 'reportId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
FeaturedId: { name: 'featuredId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
OpsUserId: { name: 'userId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
},
|
||||
schemas: {
|
||||
CreatePlazaPostRequest: {
|
||||
type: 'object',
|
||||
required: ['publication_id', 'category_id'],
|
||||
properties: {
|
||||
publication_id: { type: 'string' },
|
||||
category_id: { type: 'string' },
|
||||
category_slug: { type: 'string' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
cover_url: { type: 'string' },
|
||||
allow_comment: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
UpdatePlazaPostRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
cover_url: { type: 'string' },
|
||||
allow_comment: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
PlazaReactionRequest: {
|
||||
type: 'object',
|
||||
required: ['type'],
|
||||
properties: {
|
||||
type: { type: 'string', enum: ['like', 'collect', 'share'] },
|
||||
},
|
||||
},
|
||||
CreatePlazaCommentRequest: {
|
||||
type: 'object',
|
||||
required: ['content'],
|
||||
properties: {
|
||||
content: { type: 'string', maxLength: 500 },
|
||||
parent_id: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
PlazaReportRequest: {
|
||||
type: 'object',
|
||||
required: ['reason'],
|
||||
properties: {
|
||||
reason: {
|
||||
type: 'string',
|
||||
enum: ['spam', 'violence', 'porn', 'political', 'privacy', 'other'],
|
||||
},
|
||||
detail: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
},
|
||||
PlazaAttributionRequest: {
|
||||
type: 'object',
|
||||
required: ['event_type', 'utm_source'],
|
||||
properties: {
|
||||
event_type: { type: 'string', enum: ['landing', 'signup'] },
|
||||
utm_source: { type: 'string' },
|
||||
utm_medium: { type: 'string' },
|
||||
utm_campaign: { type: 'string' },
|
||||
ref_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
OpsReviewRequest: {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['approve', 'reject', 'hide'] },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
},
|
||||
OpsBatchReviewRequest: {
|
||||
type: 'object',
|
||||
required: ['post_ids', 'action'],
|
||||
properties: {
|
||||
post_ids: { type: 'array', items: { type: 'string' }, maxItems: 50 },
|
||||
action: { type: 'string', enum: ['approve', 'reject', 'hide'] },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
},
|
||||
OpsProcessReportRequest: {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['dismiss', 'hide_post'] },
|
||||
action_taken: { type: 'string' },
|
||||
},
|
||||
},
|
||||
OpsFeaturedRequest: {
|
||||
type: 'object',
|
||||
required: ['post_id', 'position'],
|
||||
properties: {
|
||||
post_id: { type: 'string' },
|
||||
position: { type: 'string', enum: ['homepage_banner', 'category_top', 'trending'] },
|
||||
sort_order: { type: 'integer' },
|
||||
expires_at: { type: ['string', 'null'], format: 'date-time' },
|
||||
},
|
||||
},
|
||||
OpsCreatorPatchRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verified: { type: 'boolean' },
|
||||
post_banned: { type: 'boolean' },
|
||||
comment_banned: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
buildFeedThumbnailSvg,
|
||||
buildThumbnailSvg,
|
||||
extractCoverSignals,
|
||||
generateHtmlThumbnail,
|
||||
resolveCoverDataUri,
|
||||
} from '../mindspace-thumbnails.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(root, 'public', 'thumbnail-demo');
|
||||
const samples = [
|
||||
{
|
||||
id: 'malaysia-travel',
|
||||
file: path.join(root, 'MindSpace', 'john', 'malaysia-travel-guide.html'),
|
||||
meta: {
|
||||
tag: '旅行攻略',
|
||||
subtitle: '马来西亚深度游',
|
||||
cover: 'https://images.unsplash.com/photo-1596422846543-75c6fc197f07?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mapo-tofu',
|
||||
file: path.join(root, 'MindSpace', 'john', 'mapo-tofu.html'),
|
||||
meta: {
|
||||
tag: '美食专题',
|
||||
cover: 'https://images.unsplash.com/photo-1525755662778-989d0529657e?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function galleryCard({ id, title, subtitle }) {
|
||||
return `
|
||||
<article class="feed-card">
|
||||
<div class="feed-cover"><img src="./${id}-feed.svg" alt="${title} 预览图" /></div>
|
||||
<div class="feed-body">
|
||||
<h3>${title}</h3>
|
||||
<p>${subtitle}</p>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const cards = [];
|
||||
|
||||
for (const sample of samples) {
|
||||
let html = '';
|
||||
try {
|
||||
html = await fs.readFile(sample.file, 'utf8');
|
||||
} catch {
|
||||
console.warn(`跳过缺失样本: ${sample.file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const signals = extractCoverSignals(html, sample.meta);
|
||||
const legacySvg = buildThumbnailSvg({
|
||||
title: signals.title,
|
||||
subtitle: sample.meta.subtitle ?? signals.subtitle,
|
||||
accent: signals.accent,
|
||||
});
|
||||
const coverDataUri = signals.image
|
||||
? await resolveCoverDataUri({
|
||||
storageRoot: outputDir,
|
||||
contentBaseDir: path.dirname(sample.file),
|
||||
imageUrl: signals.image,
|
||||
})
|
||||
: null;
|
||||
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
|
||||
|
||||
await fs.writeFile(path.join(outputDir, `${sample.id}-legacy.svg`), legacySvg, 'utf8');
|
||||
await fs.writeFile(path.join(outputDir, `${sample.id}-feed.svg`), feedSvg, 'utf8');
|
||||
await generateHtmlThumbnail(outputDir, `${sample.id}-generated.svg`, html, {
|
||||
...sample.meta,
|
||||
contentBaseDir: path.dirname(sample.file),
|
||||
});
|
||||
cards.push(
|
||||
galleryCard({
|
||||
id: sample.id,
|
||||
title: signals.title,
|
||||
subtitle: `${signals.tag} · 新版 3:4 封面`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MindSpace 预览图 Demo</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f0e7; color: #18211d; }
|
||||
body { margin: 0; padding: 24px; }
|
||||
h1 { margin: 0 0 8px; font-size: 28px; }
|
||||
.lead { margin: 0 0 24px; color: #5f6963; line-height: 1.6; }
|
||||
.compare { display: grid; gap: 28px; }
|
||||
.sample { padding: 20px; border-radius: 20px; background: #fff; box-shadow: 0 10px 30px rgba(24,33,29,0.08); }
|
||||
.sample h2 { margin: 0 0 16px; font-size: 20px; }
|
||||
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
|
||||
.thumb { display: grid; gap: 8px; }
|
||||
.thumb img { width: 100%; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
|
||||
.thumb span { font-size: 13px; color: #6d7771; }
|
||||
.feed-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; max-width: 520px; }
|
||||
.feed-card { border: none; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
|
||||
.feed-cover { aspect-ratio: 3 / 4; background: #ece7df; }
|
||||
.feed-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feed-body { padding: 10px 12px 12px; }
|
||||
.feed-body h3 { margin: 0; font-size: 14px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.feed-body p { margin: 6px 0 0; font-size: 12px; color: #939c97; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MindSpace 预览图升级 Demo</h1>
|
||||
<p class="lead">左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。</p>
|
||||
<div class="compare">
|
||||
${cards
|
||||
.map(
|
||||
(card, index) => `
|
||||
<section class="sample">
|
||||
<h2>样本 ${index + 1}</h2>
|
||||
<div class="pair">
|
||||
<div class="thumb"><img src="./${samples[index].id}-legacy.svg" alt="旧版" /><span>旧版(横版字卡)</span></div>
|
||||
<div class="thumb"><img src="./${samples[index].id}-feed.svg" alt="新版" /><span>新版(3:4 精美封面)</span></div>
|
||||
<div class="thumb"><img src="./${samples[index].id}-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
|
||||
</div>
|
||||
<div class="feed-grid" style="margin-top:18px">${card}</div>
|
||||
</section>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await fs.writeFile(path.join(outputDir, 'index.html'), html, 'utf8');
|
||||
console.log(`已生成预览页: ${path.join(outputDir, 'index.html')}`);
|
||||
console.log('本地查看: http://localhost:5173/thumbnail-demo/');
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user