Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.

Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env node
/**
* Sync local plaza posts (+ publications/pages/assets) to production.
*
* Usage:
* node scripts/sync-plaza-to-prod.mjs
* node scripts/sync-plaza-to-prod.mjs --dry-run
* node scripts/sync-plaza-to-prod.mjs --include-offline # re-online offline publications
*
* Env:
* DATABASE_URL local source (default from .env)
* PLAZA_PROD_DATABASE_URL production MySQL (required)
* PLAZA_PROD_SSH e.g. root@120.26.184.105
* MINDSPACE_STORAGE_ROOT local storage (default data/mindspace)
* PLAZA_PROD_STORAGE_ROOT remote storage (default .../h5/data/mindspace)
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const dryRun = process.argv.includes('--dry-run');
const includeOffline = process.argv.includes('--include-offline');
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 sourceUrl = process.env.DATABASE_URL;
const targetUrl =
process.env.PLAZA_PROD_DATABASE_URL ??
'mysql://boot:%40Abc888888@rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com:3306/goose';
const prodSsh = process.env.PLAZA_PROD_SSH ?? 'root@120.26.184.105';
const localStorageRoot = path.resolve(
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace'),
);
const remoteStorageRoot =
process.env.PLAZA_PROD_STORAGE_ROOT ?? '/root/tkmind_go/ui/h5/data/mindspace';
if (!sourceUrl) {
console.error('缺少 DATABASE_URL');
process.exit(1);
}
function quoteId(value) {
return mysql.escape(value);
}
async function resolveProdSpaceId(target, userId) {
const [rows] = await target.query(`SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`, [userId]);
if (rows[0]?.id) return rows[0].id;
throw new Error(`生产环境缺少用户 ${userId} 的 MindSpace,请先在 prod 初始化空间`);
}
async function buildProdCategoryMap(source, target, localCategoryIds) {
if (localCategoryIds.length === 0) return new Map();
const [localCats] = await source.query(`SELECT * FROM h5_space_categories WHERE id IN (?)`, [
localCategoryIds,
]);
const map = new Map();
for (const cat of localCats) {
const prodSpaceId = await resolveProdSpaceId(target, cat.user_id);
const [prodCats] = await target.query(
`SELECT id FROM h5_space_categories WHERE space_id = ? AND category_code = ? LIMIT 1`,
[prodSpaceId, cat.category_code],
);
let prodCategoryId = prodCats[0]?.id;
if (!prodCategoryId) {
prodCategoryId = cat.id;
await upsertRows(target, 'h5_space_categories', [{ ...cat, space_id: prodSpaceId }]);
}
map.set(cat.id, { spaceId: prodSpaceId, categoryId: prodCategoryId });
}
return map;
}
function remapSpaceFields(row, categoryMap) {
const mapped = categoryMap.get(row.category_id);
if (!mapped) return row;
return { ...row, space_id: mapped.spaceId, category_id: mapped.categoryId };
}
async function fetchRows(conn, table, ids) {
if (ids.length === 0) return [];
const [rows] = await conn.query(`SELECT * FROM ${table} WHERE id IN (?)`, [ids]);
return rows;
}
async function buildPlazaCategoryMap(source, target, plazaCategoryIds) {
if (plazaCategoryIds.length === 0) return new Map();
const [localCats] = await source.query(`SELECT id, slug FROM plaza_categories WHERE id IN (?)`, [
plazaCategoryIds,
]);
const map = new Map();
for (const cat of localCats) {
const [prodCats] = await target.query(`SELECT id FROM plaza_categories WHERE slug = ? LIMIT 1`, [
cat.slug,
]);
if (!prodCats[0]) throw new Error(`生产环境缺少 plaza 分类:${cat.slug}`);
map.set(cat.id, prodCats[0].id);
}
return map;
}
async function upsertRows(conn, table, rows) {
if (rows.length === 0) return 0;
let count = 0;
for (const row of rows) {
const cols = Object.keys(row);
const values = cols.map((col) => {
const value = row[col];
if (value instanceof Date) return value;
if (value !== null && typeof value === 'object') return JSON.stringify(value);
return value;
});
const placeholders = cols.map(() => '?').join(', ');
const updates = cols.filter((c) => c !== 'id').map((c) => `${c}=VALUES(${c})`).join(', ');
await conn.query(
`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
values,
);
count += 1;
}
return count;
}
function storagePaths(keys) {
const relPaths = new Set();
for (const key of keys) {
const normalized = String(key ?? '').replace(/^\/+/, '');
if (!normalized.startsWith('users/')) continue;
relPaths.add(normalized);
relPaths.add(path.dirname(normalized));
}
return [...relPaths];
}
async function main() {
const source = await mysql.createConnection(sourceUrl);
const target = await mysql.createConnection(targetUrl);
const [posts] = await source.query(
`SELECT pp.*, pr.status AS publication_status
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pp.status = 'published'
ORDER BY pp.published_at DESC`,
);
if (posts.length === 0) {
console.log('本地没有已发布的广场帖子。');
await source.end();
await target.end();
return;
}
const publicationIds = posts.map((p) => p.publication_id);
const [publications] = await source.query(`SELECT * FROM h5_publish_records WHERE id IN (?)`, [
publicationIds,
]);
const pageIds = [...new Set(publications.map((p) => p.page_id))];
const pageVersionIds = [...new Set(publications.map((p) => p.page_version_id))];
const scanIds = [...new Set(publications.map((p) => p.security_scan_id))];
const [pageVersions] = await source.query(`SELECT * FROM h5_page_versions WHERE id IN (?)`, [
pageVersionIds,
]);
const [pages] = await source.query(`SELECT * FROM h5_page_records WHERE id IN (?)`, [pageIds]);
const assetIds = new Set();
for (const version of pageVersions) {
if (version.content_asset_id) assetIds.add(version.content_asset_id);
if (version.bundle_asset_id) assetIds.add(version.bundle_asset_id);
}
const [assets] = await source.query(`SELECT * FROM h5_assets WHERE id IN (?)`, [[...assetIds]]);
const [assetVersions] = await source.query(
`SELECT * FROM h5_asset_versions WHERE asset_id IN (?)`,
[[...assetIds]],
);
const [scans] = await source.query(`SELECT * FROM h5_security_scans WHERE id IN (?)`, [scanIds]);
const spaceIds = [...new Set(pages.map((p) => p.space_id))];
const categoryIds = [
...new Set([
...pages.map((p) => p.category_id),
...assets.map((a) => a.category_id),
]),
];
const categoryMap = dryRun ? new Map() : await buildProdCategoryMap(source, target, categoryIds);
const plazaCategoryMap = dryRun
? new Map()
: await buildPlazaCategoryMap(
source,
target,
[...new Set(posts.map((p) => p.category_id))],
);
const pagesToSync = pages.map((row) => {
const next = remapSpaceFields(row, categoryMap);
const assetIdSet = new Set(assets.map((a) => a.id));
if (next.source_asset_id && !assetIdSet.has(next.source_asset_id)) {
next.source_asset_id = null;
}
if (next.cover_image_asset_id && !assetIdSet.has(next.cover_image_asset_id)) {
next.cover_image_asset_id = null;
}
return next;
});
const assetsToSync = assets.map((row) => remapSpaceFields(row, categoryMap));
const offlineCount = publications.filter((p) => p.status !== 'online').length;
const publicationsToSync = publications.map((row) => {
if (row.status === 'online' || !includeOffline) return row;
return { ...row, status: 'online', offline_at: null, updated_at: Date.now() };
});
const storageKeys = assetVersions.map((row) => row.storage_key).filter(Boolean);
const filePaths = storagePaths(storageKeys);
console.log(`准备同步 ${posts.length} 条广场帖(${publications.length} 个发布,${offlineCount} 个当前 offline`);
console.log(` 页面 ${pages.length} · 资产 ${assets.length} · 文件路径 ${filePaths.length}`);
if (dryRun) {
console.log('\n[dry-run] 将同步的帖子:');
for (const post of posts) {
const pub = publications.find((p) => p.id === post.publication_id);
console.log(` - ${post.title} (${pub?.status ?? '?'})`);
}
await source.end();
await target.end();
return;
}
await target.beginTransaction();
try {
const counts = {};
counts.scans = await upsertRows(target, 'h5_security_scans', scans);
counts.assets = await upsertRows(target, 'h5_assets', assetsToSync);
counts.assetVersions = await upsertRows(target, 'h5_asset_versions', assetVersions);
counts.pages = await upsertRows(target, 'h5_page_records', pagesToSync);
counts.pageVersions = await upsertRows(target, 'h5_page_versions', pageVersions);
counts.publications = await upsertRows(target, 'h5_publish_records', publicationsToSync);
counts.plazaPosts = await upsertRows(
target,
'plaza_posts',
posts.map(({ publication_status, ...post }) => ({
...post,
category_id: plazaCategoryMap.get(post.category_id) ?? post.category_id,
})),
);
const userIds = [...new Set(posts.map((p) => p.user_id))];
for (const userId of userIds) {
const [countRows] = await target.query(
`SELECT COUNT(*) AS c FROM plaza_posts WHERE user_id = ? AND status = 'published'`,
[userId],
);
await target.query(`UPDATE h5_users SET plaza_post_count = ?, updated_at = ? WHERE id = ?`, [
countRows[0].c,
Date.now(),
userId,
]);
}
await target.commit();
console.log('数据库同步完成:', counts);
} catch (error) {
await target.rollback();
throw error;
}
await source.end();
await target.end();
if (filePaths.length === 0) {
console.log('无需同步用户文件。');
return;
}
const includeArgs = filePaths.map((rel) => `--include=${rel}`);
includeArgs.push('--include=*/');
const rsyncCmd = [
'rsync -az',
...includeArgs,
'--exclude=*',
`${localStorageRoot}/`,
`${prodSsh}:${remoteStorageRoot}/`,
].join(' ');
console.log('==> 同步发布页面文件到 105...');
execSync(rsyncCmd, { stdio: 'inherit' });
console.log('\n✅ 广场内容已同步到生产环境');
console.log(' 首页: https://plaza.tkmind.cn/plaza');
if (offlineCount > 0 && !includeOffline) {
console.log(`\n⚠ 有 ${offlineCount} 条帖子关联的发布为 offline,未纳入同步。`);
console.log(' 若也要迁移,请重跑:node scripts/sync-plaza-to-prod.mjs --include-offline');
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});