229805a070
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
#!/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@ssh105
|
||
* 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;
|
||
const prodSsh = process.env.PLAZA_PROD_SSH ?? 'root@ssh105';
|
||
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);
|
||
}
|
||
if (!targetUrl) {
|
||
console.error('缺少 PLAZA_PROD_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);
|
||
});
|