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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user