|
|
@@ -0,0 +1,191 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+/**
|
|
|
+ * 构建 + 部署到华为云 OBS(www-ai.fmode.cn)+ 刷新 CDN(ai.fmode.cn)
|
|
|
+ *
|
|
|
+ * 用法:
|
|
|
+ * node scripts/deploy.mjs # 完整:构建 → 校验 → 上传 → 刷新 CDN → 探活
|
|
|
+ * node scripts/deploy.mjs --skip-build # 跳过构建,直接上传已有 dist
|
|
|
+ * node scripts/deploy.mjs --dry-run # 只打印将要执行的动作,不执行
|
|
|
+ *
|
|
|
+ * 前置条件:
|
|
|
+ * ① 已安装 obsutil 与 hcloud CLI,且在 `hcloud configure list` 中已配置 profile
|
|
|
+ * ② 构建配置 `npm run build` 可用(production)
|
|
|
+ *
|
|
|
+ * 🔴 与旧 deploy.ps1 的差异:
|
|
|
+ * - 项目名从 `nova-aigc` 改为 `fmode-app-v2`,产物路径改为 `dist/fmode-app-v2/browser`
|
|
|
+ * - 异常时不静默继续:任何一步失败即 `process.exit(1)`
|
|
|
+ * - 增加构建产物自检(防 demo/mock 包误发线上)
|
|
|
+ */
|
|
|
+import { execFileSync } from 'node:child_process';
|
|
|
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
+import { dirname, join, resolve } from 'node:path';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+
|
|
|
+const here = dirname(fileURLToPath(import.meta.url));
|
|
|
+const projectRoot = resolve(here, '..');
|
|
|
+const distDir = join(projectRoot, 'dist/fmode-app-v2/browser');
|
|
|
+
|
|
|
+const BUCKET = 'obs://www-ai.fmode.cn';
|
|
|
+const CDN_BASE = 'https://ai.fmode.cn';
|
|
|
+const OBS_EP = 'obs.cn-south-1.myhuaweicloud.com';
|
|
|
+const CDN_REGION = 'cn-north-1';
|
|
|
+
|
|
|
+const args = new Set(process.argv.slice(2));
|
|
|
+const skipBuild = args.has('--skip-build');
|
|
|
+const dryRun = args.has('--dry-run');
|
|
|
+
|
|
|
+/** 执行命令并回显 */
|
|
|
+function run(cmd, argv, opts = {}) {
|
|
|
+ const printable = `${cmd} ${argv.join(' ')}`;
|
|
|
+ console.log(`\n$ ${printable}`);
|
|
|
+ if (dryRun) return '';
|
|
|
+ try {
|
|
|
+ return execFileSync(cmd, argv, { cwd: projectRoot, stdio: 'inherit', ...opts });
|
|
|
+ } catch (e) {
|
|
|
+ console.error(`\n❌ 命令失败:${printable}`);
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 探活用 Node 原生 fetch,不依赖 curl。
|
|
|
+ * Windows 上的 curl 是原生程序,`-o /dev/null` 会被当成文件路径写盘,不能拿来判状态码。
|
|
|
+ */
|
|
|
+async function probe(url, timeoutMs = 30_000) {
|
|
|
+ const ac = new AbortController();
|
|
|
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
|
+ try {
|
|
|
+ const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
|
|
+ return { status: res.status, body: await res.text() };
|
|
|
+ } catch {
|
|
|
+ return { status: 0, body: '' };
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ── 1. 构建 ────────────────────────────────────────────────────────────────
|
|
|
+if (!skipBuild) {
|
|
|
+ console.log('\n=== [1/5] 构建 production ===');
|
|
|
+ run('npm', ['run', 'build']);
|
|
|
+} else {
|
|
|
+ console.log('\n=== [1/5] 跳过构建(--skip-build)===');
|
|
|
+}
|
|
|
+
|
|
|
+// ── 2. 产物自检 ────────────────────────────────────────────────────────────
|
|
|
+console.log('\n=== [2/5] 构建产物自检 ===');
|
|
|
+if (!existsSync(distDir)) {
|
|
|
+ console.error(`❌ 产物目录不存在:${distDir}`);
|
|
|
+ process.exit(1);
|
|
|
+}
|
|
|
+const files = readdirSync(distDir);
|
|
|
+const indexHtml = join(distDir, 'index.html');
|
|
|
+if (!files.includes('index.html')) {
|
|
|
+ console.error('❌ 缺少 index.html');
|
|
|
+ process.exit(1);
|
|
|
+}
|
|
|
+const html = readFileSync(indexHtml, 'utf8');
|
|
|
+if (!/app-root/.test(html)) {
|
|
|
+ console.error('❌ index.html 不含 <app-root>,产物异常');
|
|
|
+ process.exit(1);
|
|
|
+}
|
|
|
+// 主包与依赖 chunk 中若含演示数据开关且为 true,说明误发了 mock 包
|
|
|
+const jsFiles = files.filter((f) => f.endsWith('.js'));
|
|
|
+let sawMockSwitch = false;
|
|
|
+for (const f of jsFiles) {
|
|
|
+ const src = readFileSync(join(distDir, f), 'utf8');
|
|
|
+ const m = src.match(/useMockData\s*:\s*!?[01]/);
|
|
|
+ if (!m) continue;
|
|
|
+ sawMockSwitch = true;
|
|
|
+ if (/!\s*0/.test(m[0])) {
|
|
|
+ console.error(`❌ 检测到演示数据包(${f} 中 useMockData: true),拒绝部署到线上`);
|
|
|
+ console.error(' 线上请用 `npm run build`;演示包请用 `npm run build:mock` 走本地 mock 服务');
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+}
|
|
|
+if (sawMockSwitch) {
|
|
|
+ console.log(' ✓ 数据源开关 = 真实(useMockData: false)');
|
|
|
+} else {
|
|
|
+ console.warn(' ⚠ 未在产物中找到 useMockData 开关(可能已被 tree-shake),跳过校验');
|
|
|
+}
|
|
|
+console.log(` ✓ index.html + ${files.length} 个文件`);
|
|
|
+
|
|
|
+// ── 3. 上传 OBS ────────────────────────────────────────────────────────────
|
|
|
+console.log('\n=== [3/5] 同步到 OBS ===');
|
|
|
+/*
|
|
|
+ * 先 sync 覆盖、再清多余文件:不用先清空桶,避免「清空后上传失败」导致站点空窗。
|
|
|
+ * `-f` 跳过确认;**不传 `-delete`** —— 桶里可能还有非本站资源(assets/ 等),
|
|
|
+ * 本应删除的旧产物需人工确认,见 README「部署」一节。
|
|
|
+ * OBS 兼容 S3 协议,AK/SK 由 obsutil 自身的配置(`obsutil config`)提供,不写在脚本里。
|
|
|
+ */
|
|
|
+run('obsutil', ['sync', distDir, BUCKET, '-f', `-e=${OBS_EP}`]);
|
|
|
+run('obsutil', ['chattri', BUCKET, '-r', '-f', `-e=${OBS_EP}`, '-acl=public-read']);
|
|
|
+
|
|
|
+// ── 4. 刷新 CDN ────────────────────────────────────────────────────────────
|
|
|
+console.log('\n=== [4/5] 刷新 CDN ===');
|
|
|
+const profile = process.env.HCLOUD_PROFILE || 'default';
|
|
|
+const refreshArgs = [
|
|
|
+ 'CDN',
|
|
|
+ 'CreateRefreshTasks/v2',
|
|
|
+ `--cli-region=${CDN_REGION}`,
|
|
|
+ `--refresh_task.urls.1=${CDN_BASE}/`,
|
|
|
+ `--refresh_task.urls.2=${CDN_BASE}/index.html`,
|
|
|
+ `--cli-profile=${profile}`,
|
|
|
+];
|
|
|
+// KooCLI 首次运行会交互式询问《隐私政策声明》,无人值守时会卡死并报 USE_ERROR
|
|
|
+run('hcloud', refreshArgs, { input: 'y\ny\ny\n' });
|
|
|
+
|
|
|
+// ── 5. 探活 ────────────────────────────────────────────────────────────────
|
|
|
+console.log('\n=== [5/5] 线上探活 ===');
|
|
|
+if (dryRun) {
|
|
|
+ console.log(' (dry-run,跳过)');
|
|
|
+} else {
|
|
|
+ /*
|
|
|
+ * 每个 URL 轮询至多 3 分钟(CDN 刷新实际有 1-5 分钟延迟),
|
|
|
+ * 命中 200 / 内容是本次构建时才判通过 —— 只发请求不看结果的「探活」等于没探。
|
|
|
+ */
|
|
|
+ const indexHtmlContent = readFileSync(indexHtml, 'utf8');
|
|
|
+ const title = indexHtmlContent.match(/<title>([^<]*)<\/title>/)?.[1] ?? '';
|
|
|
+ let allOk = true;
|
|
|
+ for (const path of ['/', '/index.html']) {
|
|
|
+ let ok = false;
|
|
|
+ for (let i = 0; i < 18 && !ok; i++) {
|
|
|
+ const { status } = await probe(`${CDN_BASE}${path}`);
|
|
|
+ ok = status === 200;
|
|
|
+ if (!ok) await new Promise((r) => setTimeout(r, 10_000));
|
|
|
+ }
|
|
|
+ if (ok) console.log(` ✓ ${path} → 200`);
|
|
|
+ else {
|
|
|
+ allOk = false;
|
|
|
+ console.log(` ❌ ${path} 轮询 3 分钟仍未返回 200,请手动确认 CDN 状态`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (title) {
|
|
|
+ const { body } = await probe(`${CDN_BASE}/`);
|
|
|
+ if (body.includes(title)) console.log(` ✓ 线上标题为本次构建:${title}`);
|
|
|
+ else {
|
|
|
+ allOk = false;
|
|
|
+ console.log(` ⚠ 线上标题非本次构建(期望「${title}」),CDN 可能尚未刷新完`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /*
|
|
|
+ * SPA 深链:OBS 静态网站托管用 ErrorDocument=index.html 回落 ——
|
|
|
+ * **状态码仍是 404**(OBS/S3 的标准行为),但响应体是 index.html,浏览器据此正常启动。
|
|
|
+ * 故判据是「响应体含本次构建的 <title>」,而不是状态码。
|
|
|
+ * 若将来换到 CDN 回源改写(状态码 200),这条断言同样成立。
|
|
|
+ */
|
|
|
+ const deep = await probe(`${CDN_BASE}/desktop/chat`);
|
|
|
+ if (title && deep.body.includes(title)) {
|
|
|
+ console.log(' ✓ SPA 深链 /desktop/chat 回落到 index.html');
|
|
|
+ } else {
|
|
|
+ allOk = false;
|
|
|
+ console.log(' ❌ SPA 深链 /desktop/chat 未回落(需在 OBS 桶「静态网站托管」把错误文档设为 index.html)');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!allOk) {
|
|
|
+ console.error('\n❌ 部署完成但线上校验未全部通过,见上方 ❌ / ⚠ 项');
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+console.log(`\n✅ 部署完成 → ${CDN_BASE}\n`);
|