6d99d762da
Introduce AI image generation with chat shortcut and agent API, improve MindSpace chat-to-page save resolution, and seed Plaza covers with production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createDbPool, initSchema } from '../db.mjs';
|
|
import { createPlazaPostService } from '../plaza-posts.mjs';
|
|
import { initializeDefaultSpace } from '../mindspace.mjs';
|
|
import { EXTRA_DEMO_POSTS } from './plaza-demo-posts.mjs';
|
|
|
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
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'));
|
|
|
|
const BASE_DEMO_POSTS = [
|
|
{
|
|
category: 'work-report',
|
|
title: 'Q2 产品增长复盘:从 0 到 10 万 DAU',
|
|
summary: '拆解增长漏斗、渠道投放 ROI 与留存曲线,附可复用的周报模板。',
|
|
tags: ['增长', '复盘', '产品'],
|
|
likes: 128,
|
|
comments: 24,
|
|
views: 3200,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'work-report',
|
|
title: 'AI 客服落地 30 天执行报告',
|
|
summary: '从需求澄清、知识库搭建到上线监控,记录真实踩坑与指标变化。',
|
|
tags: ['AI', '客服', '落地'],
|
|
likes: 86,
|
|
comments: 11,
|
|
views: 1840,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'work-report',
|
|
title: '供应链数字化周报 · 第 18 期',
|
|
summary: '库存周转、缺货预警与供应商协同进展,适合制造业团队参考。',
|
|
tags: ['供应链', '周报'],
|
|
likes: 45,
|
|
comments: 6,
|
|
views: 920,
|
|
author: 'admin',
|
|
},
|
|
{
|
|
category: 'study-notes',
|
|
title: 'Rust 所有权模型 15 分钟图解',
|
|
summary: '用生活类比讲清 borrow / move / lifetime,附练习题与答案。',
|
|
tags: ['Rust', '编程', '笔记'],
|
|
likes: 203,
|
|
comments: 37,
|
|
views: 5100,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'study-notes',
|
|
title: 'LLM Prompt 工程速查表',
|
|
summary: '角色设定、Few-shot、CoT、结构化输出,一张表搞定常见场景。',
|
|
tags: ['Prompt', 'LLM'],
|
|
likes: 312,
|
|
comments: 58,
|
|
views: 8900,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'creative',
|
|
title: '赛博水墨城市概念海报',
|
|
summary: 'MindSpace 生成的东方未来主义视觉,适合活动主 KV 参考。',
|
|
tags: ['设计', '海报', 'AI绘画'],
|
|
likes: 167,
|
|
comments: 19,
|
|
views: 4200,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'creative',
|
|
title: '独立音乐人首张 EP 视觉方案',
|
|
summary: '封面、社媒九宫格与演出海报的统一视觉语言。',
|
|
tags: ['音乐', '品牌'],
|
|
likes: 74,
|
|
comments: 9,
|
|
views: 1600,
|
|
author: 'admin',
|
|
},
|
|
{
|
|
category: 'business',
|
|
title: '社区咖啡店开业 7 天运营手记',
|
|
summary: '选址理由、爆款 SKU、会员拉新与坪效测算一页看懂。',
|
|
tags: ['咖啡', '开店'],
|
|
likes: 96,
|
|
comments: 15,
|
|
views: 2400,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'business',
|
|
title: '普拉提工作室 618 活动页',
|
|
summary: '课程包组合、限时优惠与预约转化话术,可直接复用结构。',
|
|
tags: ['健身', '618'],
|
|
likes: 58,
|
|
comments: 7,
|
|
views: 1300,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'travel',
|
|
title: '马来西亚 5 日轻量旅行攻略',
|
|
summary: '吉隆坡 + 槟城路线、美食清单与预算表,适合周末出发。',
|
|
tags: ['马来西亚', '攻略'],
|
|
likes: 241,
|
|
comments: 42,
|
|
views: 7600,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'travel',
|
|
title: '京都红叶季小众散步地图',
|
|
summary: '避开人潮的 8 条街道与 3 家深夜食堂,附交通换乘。',
|
|
tags: ['日本', '红叶'],
|
|
likes: 189,
|
|
comments: 31,
|
|
views: 5400,
|
|
author: 'admin',
|
|
},
|
|
{
|
|
category: 'data-analysis',
|
|
title: '电商大促 GMV 归因看板',
|
|
summary: '渠道、品类、客单三层下钻,附 SQL 与可视化截图说明。',
|
|
tags: ['电商', 'BI'],
|
|
likes: 112,
|
|
comments: 18,
|
|
views: 2900,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'data-analysis',
|
|
title: '用户留存 Cohort 分析模板',
|
|
summary: '按注册周看 D1/D7/D30 留存,含异常波动排查 checklist。',
|
|
tags: ['留存', '分析'],
|
|
likes: 77,
|
|
comments: 12,
|
|
views: 2100,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'lifestyle',
|
|
title: '极简桌面改造:提升专注的 12 个细节',
|
|
summary: '线缆收纳、光照、显示器高度与数字断舍离清单。',
|
|
tags: ['桌面', '效率'],
|
|
likes: 134,
|
|
comments: 21,
|
|
views: 3600,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'lifestyle',
|
|
title: '晨间写作 21 天挑战记录',
|
|
summary: '每天 20 分钟自由书写,记录情绪变化与可执行习惯。',
|
|
tags: ['写作', '习惯'],
|
|
likes: 63,
|
|
comments: 14,
|
|
views: 1500,
|
|
author: 'admin',
|
|
},
|
|
{
|
|
category: 'other',
|
|
title: '给未来的自己:2026 愿望清单',
|
|
summary: '学习、健康、关系与创作四个维度的年度目标墙。',
|
|
tags: ['年度规划'],
|
|
likes: 39,
|
|
comments: 8,
|
|
views: 980,
|
|
author: 'john',
|
|
},
|
|
{
|
|
category: 'other',
|
|
title: '开源副业项目冷启动笔记',
|
|
summary: '从 0 Star 到首批付费用户的 6 个关键动作。',
|
|
tags: ['开源', '副业'],
|
|
likes: 91,
|
|
comments: 16,
|
|
views: 2200,
|
|
author: 'john',
|
|
},
|
|
];
|
|
|
|
const DEMO_POSTS = [...BASE_DEMO_POSTS, ...EXTRA_DEMO_POSTS];
|
|
|
|
async function loadUser(pool, username) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, username, slug, display_name FROM h5_users WHERE username = ? LIMIT 1`,
|
|
[username],
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function ensureUserSpace(pool, userId) {
|
|
const [[space]] = await pool.query(
|
|
`SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
|
|
[userId],
|
|
);
|
|
if (space?.id) return;
|
|
await initializeDefaultSpace(pool, userId);
|
|
}
|
|
|
|
async function createDemoPublication(pool, userId, demo, index) {
|
|
const now = Date.now() - index * 3_600_000;
|
|
const publicationId = crypto.randomUUID();
|
|
const pageId = crypto.randomUUID();
|
|
const pageVersionId = crypto.randomUUID();
|
|
const contentAssetId = crypto.randomUUID();
|
|
const assetVersionId = crypto.randomUUID();
|
|
const scanId = crypto.randomUUID();
|
|
const slug = `demo-${demo.category}-${index}-${publicationId.slice(0, 6)}`;
|
|
|
|
const [[space]] = await pool.query(
|
|
`SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
|
|
[userId],
|
|
);
|
|
const [[category]] = await pool.query(
|
|
`SELECT id FROM h5_space_categories WHERE user_id = ? AND category_code = 'draft' LIMIT 1`,
|
|
[userId],
|
|
);
|
|
if (!space?.id || !category?.id) throw new Error(`用户 ${userId} 缺少空间或分类`);
|
|
|
|
const [[user]] = await pool.query(`SELECT slug, username FROM h5_users WHERE id = ? LIMIT 1`, [userId]);
|
|
const ownerSlug = user?.slug || user?.username || 'user';
|
|
const publicUrl = `/u/${ownerSlug}/pages/${slug}`;
|
|
|
|
await pool.query(
|
|
`INSERT INTO h5_assets
|
|
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename, display_name,
|
|
current_version_id, size_bytes, checksum, visibility, status, source_type, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'content', 'text/markdown', 'page.md', ?, ?, 128, ?, 'private', 'ready', 'upload', ?, ?)`,
|
|
[contentAssetId, userId, space.id, category.id, demo.title, assetVersionId, 'demo-seed', now, now],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO h5_asset_versions
|
|
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, created_by, created_at)
|
|
VALUES (?, ?, 1, ?, 128, ?, 'text/markdown', ?, ?)`,
|
|
[assetVersionId, contentAssetId, `users/${userId}/assets/${contentAssetId}/v1`, 'demo-seed', userId, now],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO h5_page_records
|
|
(id, user_id, space_id, category_id, title, summary, page_type, template_id, status, visibility, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'article', 'editorial', 'published', 'public', ?, ?)`,
|
|
[pageId, userId, space.id, category.id, demo.title, demo.summary, now, now],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO h5_security_scans
|
|
(id, user_id, target_type, target_id, scanner_version, status, risk_level, findings_count, started_at, completed_at)
|
|
VALUES (?, ?, 'page_version', ?, 'seed', 'passed', 'none', 0, ?, ?)`,
|
|
[scanId, userId, pageVersionId, now, now],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO h5_page_versions
|
|
(id, page_id, version_no, content_asset_id, created_by, immutable, created_at)
|
|
VALUES (?, ?, 1, ?, ?, 1, ?)`,
|
|
[pageVersionId, pageId, contentAssetId, userId, now],
|
|
);
|
|
await pool.query(
|
|
`UPDATE h5_page_records SET current_version_id = ?, updated_at = ? WHERE id = ?`,
|
|
[pageVersionId, now, pageId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO h5_publish_records
|
|
(id, user_id, page_id, page_version_id, url_slug, public_url, access_mode, published_at, status,
|
|
view_count, security_scan_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'public', ?, 'online', 0, ?, ?, ?)`,
|
|
[publicationId, userId, pageId, pageVersionId, slug, publicUrl, now, scanId, now, now],
|
|
);
|
|
|
|
return { publicationId, publicUrl };
|
|
}
|
|
|
|
async function bumpPostStats(pool, postId, demo) {
|
|
await pool.query(
|
|
`UPDATE plaza_posts
|
|
SET view_count = ?, like_count = ?, comment_count = ?, hot_score = ?, hot_updated_at = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[demo.views, demo.likes, demo.comments, demo.likes * 3 + demo.comments * 5, Date.now(), Date.now(), postId],
|
|
);
|
|
}
|
|
|
|
const pool = createDbPool();
|
|
await initSchema(pool);
|
|
await createPlazaPostService(pool).ensureCategories();
|
|
|
|
const users = {};
|
|
for (const name of ['john', 'admin']) {
|
|
const user = await loadUser(pool, name);
|
|
if (!user) {
|
|
console.warn(`跳过:用户 ${name} 不存在`);
|
|
continue;
|
|
}
|
|
await ensureUserSpace(pool, user.id);
|
|
users[name] = user;
|
|
}
|
|
|
|
const plazaPosts = createPlazaPostService(pool);
|
|
let created = 0;
|
|
let skipped = 0;
|
|
|
|
for (const [index, demo] of DEMO_POSTS.entries()) {
|
|
const user = users[demo.author] ?? users.john;
|
|
if (!user) continue;
|
|
|
|
const { publicationId } = await createDemoPublication(pool, user.id, demo, index);
|
|
try {
|
|
const post = await plazaPosts.createPost(user.id, {
|
|
publication_id: publicationId,
|
|
category_slug: demo.category,
|
|
tags: demo.tags,
|
|
allow_comment: true,
|
|
});
|
|
await plazaPosts.reviewPost(post.id, 'approve');
|
|
await bumpPostStats(pool, post.id, demo);
|
|
created += 1;
|
|
console.log(`✓ [${demo.category}] ${demo.title}`);
|
|
} catch (error) {
|
|
if (error?.code === 'ALREADY_PUBLISHED') {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const [pendingRows] = await pool.query(
|
|
`SELECT id FROM plaza_posts WHERE status = 'pending_review'`,
|
|
);
|
|
if (pendingRows.length > 0) {
|
|
for (const row of pendingRows) {
|
|
await plazaPosts.reviewPost(row.id, 'approve');
|
|
}
|
|
console.log(`✓ 已通过 ${pendingRows.length} 条待审核帖子`);
|
|
}
|
|
|
|
await pool.end();
|
|
console.log(`\n完成:新增 ${created} 条演示帖,跳过 ${skipped} 条。`);
|
|
console.log('刷新查看:http://localhost:3001/plaza');
|
|
console.log('职场报告:http://localhost:3001/plaza/cat/work-report');
|