diff --git a/docs/PRODUCTION_MOBILE_DEPLOYMENT_RUNBOOK.md b/docs/PRODUCTION_MOBILE_DEPLOYMENT_RUNBOOK.md new file mode 100644 index 0000000..d40c195 --- /dev/null +++ b/docs/PRODUCTION_MOBILE_DEPLOYMENT_RUNBOOK.md @@ -0,0 +1,449 @@ +# frontend-miniapp 正式环境部署手册 + +最后更新:2026-07-15 + +## 适用范围 + +本文档适用于将本地仓库 `F:\深圳自然博物馆\frontend-miniapp` 的 H5 产物部署到深圳自然博物馆正式环境。 + +- 正式域名:`https://guide.sznhmuseum.org.cn` +- 主节点:`172.20.14.21` +- 部署目录:`/data/nginx/html/mobile` +- Nginx 容器:`sgs-nginx` +- 同步脚本:`/data/scripts/sync-frontend-nodes.sh` +- 同步节点:`172.20.14.23`、`172.20.14.26`、`172.20.14.27`、`172.20.14.33` +- 远程仓库:`http://192.168.0.93:3333/lyf/frontend-miniapp.git` + +不要把服务器密码、证书私钥、token、真实后台账号、腾讯地图正式 key 写入仓库文档或提交记录。 + +## 部署前检查 + +进入项目目录: + +```powershell +cd 'F:\深圳自然博物馆\frontend-miniapp' +``` + +检查分支、远程仓库和本地改动: + +```powershell +git status --short --branch --untracked-files=all +git log -1 --pretty=format:'%h %s' +git remote -v +``` + +正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括: + +```text +.env.production +src/config/dataSource.ts +src/data/adapters/guideDataAdapter.ts +src/data/providers/staticMuseumContentProvider.ts +src/env.d.ts +src/repositories/ExplainRepository.ts +src/usecases/explainUseCase.ts +src/utils/publicUrl.ts +static/guide-data/exhibits.json +static/guide-data/guide-contents.json +static/guide-data/guide-stops.json +static/guide-data/manifest.json +``` + +## 拉取远程最新代码 + +先临时保存本地生产补丁: + +```powershell +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$msg = "pre-deploy-local-prod-fixes-$stamp" +if (git status --porcelain) { + git stash push -u -m $msg + Write-Output "STASHED=$msg" +} +``` + +拉取远程最新代码: + +```powershell +git fetch origin +git pull --ff-only origin master +git log -1 --pretty=format:'%h %s' +``` + +恢复生产补丁: + +```powershell +$stash = git stash list | Select-String $msg | Select-Object -First 1 +if ($stash) { + $stashRef = ($stash.Line -split ':')[0] + git stash pop $stashRef +} +git status --short --branch --untracked-files=all +``` + +如出现冲突,先解决冲突并重新确认生产配置,再继续构建。 + +## 本地构建 + +安装依赖并做类型检查: + +```powershell +corepack pnpm install --frozen-lockfile +$env:NODE_OPTIONS='--max-old-space-size=16384' +corepack pnpm type-check +``` + +设置正式构建变量。腾讯地图 key 只通过环境变量注入,不写入仓库: + +```powershell +$env:NODE_OPTIONS='--max-old-space-size=16384' +$env:VITE_TENCENT_MAP_KEY='<正式腾讯地图 Web Key>' +$env:VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST='172.20.14.21' +$env:VITE_EXPLAIN_ALLOW_STATIC_FALLBACK='true' +``` + +执行正式 H5 构建: + +```powershell +corepack pnpm build:h5 +``` + +`build:h5` 会执行: + +1. `uni build -p h5 --mode production` +2. `node scripts/finalize-h5-build.cjs --require-tencent-map-key` + +`finalize-h5-build.cjs` 会自动完成: + +- 拷贝 `static/nav-assets`、`static/guide-data`、`static/guide`、`static/icons`、`static/explain`、`static/sgs-map-sdk`、`static/three` 和字体资源。 +- 使用 Node 按 UTF-8 安全替换构建产物里的 `__REPLACE_WITH_TENCENT_MAP_WEB_KEY__`。 +- 对全部构建 JS 执行 `node --check`,防止语法错误产物上线。 +- 扫描并阻断 `1.92.206.90`、`guide.whaoyue.com`、腾讯地图 key 占位符等不应进入正式环境的字符串。 +- 校验核心静态 JSON 和关键图标文件存在。 + +如 terser 压缩阶段出现内存不足,可使用不压缩构建: + +```powershell +corepack pnpm run build:h5:no-minify +``` + +严禁再用 PowerShell `Get-Content` / `Set-Content` 手工替换构建后的 JS。PowerShell 默认编码容易破坏 UTF-8 中文注释或字符串,曾导致线上入口 JS 语法错误、页面空白。 + +## 构建产物检查 + +构建脚本会自动输出类似: + +```text +H5_ENTRY=assets/index-xxxx.js +H5_ENTRY_SIZE=246232 +H5_JS_SYNTAX_CHECKED=... +H5_TENCENT_PLACEHOLDER_REPLACED_FILES=2 +H5_BAD_STRING_FILE_COUNT=0 +H5_MANIFEST_SOURCE_HOST=172.20.14.21 +H5_GUIDE_CONTENTS_ROWS=720 +H5_EXHIBITS_ROWS=4700 +H5_GUIDE_STOPS_ROWS=398 +``` + +如果需要手工复查入口 JS: + +```powershell +$dist = Join-Path (Get-Location) 'dist\build\h5' +$html = Get-Content -LiteralPath (Join-Path $dist 'index.html') -Raw +$entry = [regex]::Match($html, 'src="/?([^"]*assets/index-[^"]+\.js)"').Groups[1].Value +node --check (Join-Path $dist $entry) +``` + +预期无错误输出。 + +## 打包与上传 + +```powershell +$ts = Get-Date -Format 'yyyyMMddHHmmss' +New-Item -ItemType Directory -Force -Path '.tmp' | Out-Null +$pkg = ".tmp\frontend-miniapp-h5-$ts.tar.gz" +if (Test-Path $pkg) { Remove-Item -LiteralPath $pkg -Force } +tar -czf $pkg -C 'dist\build\h5' . +scp -o StrictHostKeyChecking=no $pkg root@172.20.14.21:/tmp/frontend-miniapp-h5.tar.gz +``` + +## 主节点部署 + +部署时必须保留: + +- `/data/nginx/html/mobile/gpL0svkeao.txt` +- `/data/nginx/html/mobile/.well-known` + +执行: + +```powershell +$script = @' +set -euo pipefail +pkg=/tmp/frontend-miniapp-h5.tar.gz +target=/data/nginx/html/mobile +backup_dir=/data/nginx/html/_backups +ts=$(date +%Y%m%d%H%M%S) +backup="$backup_dir/mobile-before-deploy-$ts.tar.gz" +tmp="/tmp/frontend-miniapp-h5-$ts" + +test -s "$pkg" +mkdir -p "$target" "$backup_dir" +tar -czf "$backup" -C "$target" . +rm -rf "$tmp" +mkdir -p "$tmp" +tar -xzf "$pkg" -C "$tmp" +test -f "$tmp/index.html" + +find "$target" -mindepth 1 -maxdepth 1 ! -name 'gpL0svkeao.txt' ! -name '.well-known' -exec rm -rf {} + +cp -a "$tmp"/. "$target"/ +chmod -R a+rX "$target" + +entry=$(grep -o "assets/index-[^\"]*\.js" "$target/index.html" | head -n 1 || true) +test -n "$entry" +test -f "$target/$entry" +node --check "$target/$entry" + +if grep -R -I -E '1\.92\.206\.90|guide\.whaoyue\.com|__REPLACE_WITH_TENCENT_MAP_WEB_KEY__|http://1\.92\.206\.90:9000' "$target" >/tmp/mobile_bad_matches.txt; then + cat /tmp/mobile_bad_matches.txt >&2 + exit 1 +fi + +docker exec sgs-nginx nginx -t +docker exec sgs-nginx nginx -s reload + +printf 'DEPLOY_BACKUP=%s\n' "$backup" +printf 'DEPLOY_ENTRY=%s\n' "$entry" +printf 'DEPLOY_TARGET=%s\n' "$target" +printf 'PRESERVED_GP=%s\n' "$(test -f "$target/gpL0svkeao.txt" && echo yes || echo no)" +printf 'PRESERVED_WELL_KNOWN=%s\n' "$(test -d "$target/.well-known" && echo yes || echo no)" +'@ +$script | ssh -o StrictHostKeyChecking=no root@172.20.14.21 'bash -s' +``` + +## 执行同步脚本 + +```powershell +ssh -o StrictHostKeyChecking=no root@172.20.14.21 'bash /data/scripts/sync-frontend-nodes.sh' +``` + +预期最后输出: + +```text +All frontend worker nodes synced. +``` + +同步脚本会同步 `/data/nginx/html`,同时也会处理 `/data/apps/sgs-map` 并通过 PM2 重启 `sgs-map`。这是当前脚本行为,部署 mobile 时也会看到相关输出。 + +## 线上验证 + +公网验证: + +```powershell +$base = 'https://guide.sznhmuseum.org.cn' +curl.exe -L -s -o NUL -w "home=%{http_code} %{content_type} %{size_download}`n" "$base/" +curl.exe -L -s -o NUL -w "guide_contents=%{http_code} %{content_type} %{size_download}`n" "$base/static/guide-data/guide-contents.json" +curl.exe -L -s -o NUL -w "exhibits=%{http_code} %{content_type} %{size_download}`n" "$base/static/guide-data/exhibits.json" +curl.exe -L -s -o NUL -w "manifest=%{http_code} %{content_type} %{size_download}`n" "$base/static/guide-data/manifest.json" +curl.exe -L -s -o NUL -w "brand_icon=%{http_code} %{content_type} %{size_download}`n" "$base/static/guide/museum-brand-icons.webp" +curl.exe -L -s -o NUL -w "shortcut_icon=%{http_code} %{content_type} %{size_download}`n" "$base/static/icons/poi/shortcut-icons.svg" +curl.exe -L -s -o NUL -w "verify_txt=%{http_code} %{content_type} %{size_download}`n" "$base/gpL0svkeao.txt" +``` + +入口 JS 验证: + +```powershell +$tmp = Join-Path (Get-Location) '.tmp\public-mobile-verify' +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$htmlPath = Join-Path $tmp 'index.html' +$entryPath = Join-Path $tmp 'entry.js' +curl.exe -L -s "$base/" -o $htmlPath +$html = Get-Content -LiteralPath $htmlPath -Raw +$entry = [regex]::Match($html, 'src="/?([^"]*assets/index-[^"]+\.js)"').Groups[1].Value +curl.exe -L -s "$base/$entry" -o $entryPath +node --check $entryPath +@('1.92.206.90','guide.whaoyue.com','__REPLACE_WITH_TENCENT_MAP_WEB_KEY__') | + Where-Object { (Get-Content -LiteralPath $entryPath -Raw).Contains($_) } +``` + +预期 `node --check` 通过,旧测试地址和占位符检查无输出。 + +五台节点一致性验证: + +```powershell +$nodes = @('172.20.14.21','172.20.14.23','172.20.14.26','172.20.14.27','172.20.14.33') +$entryPath = "/data/nginx/html/mobile/$entry" +foreach ($node in $nodes) { + ssh -o StrictHostKeyChecking=no root@$node "node --check '$entryPath' >/dev/null && sha256sum '$entryPath' && stat -c %s '$entryPath'" +} +``` + +五台节点的 size 和 SHA256 应一致。 + +## 回滚 + +主节点部署脚本会在 `/data/nginx/html/_backups` 下生成备份,例如: + +```text +/data/nginx/html/_backups/mobile-before-deploy-YYYYMMDDHHmmss.tar.gz +``` + +回滚主节点: + +```powershell +$backup = '/data/nginx/html/_backups/mobile-before-deploy-YYYYMMDDHHmmss.tar.gz' +$script = @" +set -euo pipefail +target=/data/nginx/html/mobile +backup='$backup' +test -f "`$backup" +find "`$target" -mindepth 1 -maxdepth 1 ! -name 'gpL0svkeao.txt' ! -name '.well-known' -exec rm -rf {} + +tar -xzf "`$backup" -C "`$target" +chmod -R a+rX "`$target" +docker exec sgs-nginx nginx -t +docker exec sgs-nginx nginx -s reload +"@ +$script | ssh -o StrictHostKeyChecking=no root@172.20.14.21 'bash -s' +``` + +回滚后仍需执行同步脚本: + +```powershell +ssh -o StrictHostKeyChecking=no root@172.20.14.21 'bash /data/scripts/sync-frontend-nodes.sh' +``` + +## 已遇到的问题与处理 + +### 1. terser 压缩阶段内存不足 + +现象: + +```text +[vite:terser] Worker terminated due to reaching memory limit: JS heap out of memory +``` + +处理: + +- 设置 `NODE_OPTIONS=--max-old-space-size=16384`。 +- 优先使用 `corepack pnpm build:h5`。 +- 如仍 OOM,使用 `corepack pnpm run build:h5:no-minify`,该命令仍会执行安全后处理和 `node --check`。 + +### 2. 测试服务器 IP 混入正式环境 + +现象: + +```text +Mixed Content: requested insecure image 'http://1.92.206.90:9000/...' +``` + +处理: + +- 正式环境使用 `VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST=172.20.14.21`。 +- 构建后必须扫描 `1.92.206.90`、`guide.whaoyue.com`。 +- 静态资源通过同源路径 `/museum-assets/...` 访问。 + +### 3. 腾讯地图接口 CORS + +现象: + +```text +Access to XMLHttpRequest at 'https://apis.map.qq.com/...' has been blocked by CORS policy +``` + +处理: + +- 避免前端直接跨域请求不可 CORS 的接口。 +- 需要由 Nginx 或后端代理转发第三方地图 API,前端访问同源路径。 +- 构建产物中的腾讯地图 key 占位符必须替换为正式 key。 + +### 4. 3D 模型或 JSON 返回 HTML + +现象: + +```text +SyntaxError: Unexpected token '<', "=22.0.0', npm: '>=10.5.1' } +current: { node: 'v20.19.5', npm: '10.8.2' } +``` + +处理: + +- 当前为已知非阻断 warning。 +- 只要脚本最终输出 `All frontend worker nodes synced.`,且 PM2 中 `sgs-map` 为 `online`,本次 mobile 静态部署可继续验收。 + +### 7. 同步脚本会重启 sgs-map + +现象: + +- 部署 mobile 时,脚本仍会同步 `/data/apps/sgs-map` 并重启 PM2 进程 `sgs-map`。 + +处理: + +- 当前按脚本现状执行。 +- 验收时关注最终 PM2 状态是否 `online`。 +- 如未来只需同步 mobile,可拆分或新增专用同步脚本,避免不必要地重启 map 服务。 + +### 8. 页面返回 200 但浏览器空白 + +现象: + +```text +https://guide.sznhmuseum.org.cn/ HTTP 200 +浏览器控制台:SyntaxError: missing ) after argument list +``` + +本次根因: + +- 入口 JS 本身语法损坏,不是 Nginx、证书或后端问题。 +- 故障版本中,构建后手工用 PowerShell `Get-Content` / `Set-Content` 替换腾讯地图 key,错误编码重写了 JS,破坏了中文注释或字符串。 +- 典型错误位置表现为 uni 运行时代码附近的中文注释被改写,导致 `node --check` 报 `missing ) after argument list` 或 `Unexpected identifier`。 + +修复: + +- 重新生成干净 H5 产物。 +- 使用 `scripts/finalize-h5-build.cjs` 按 UTF-8 替换腾讯地图 key。 +- 部署前、主节点替换后、线上验证时都执行 `node --check`。 + +预防: + +- 不再手工替换构建产物 JS。 +- 标准构建必须使用 `corepack pnpm build:h5` 或 `corepack pnpm run build:h5:no-minify`。 +- 部署脚本中保留 `node --check "$target/$entry"`。 + +## 最近正式部署记录 + +- 2026-07-13:多次部署 `frontend-miniapp` 到 `172.20.14.21:/data/nginx/html/mobile`,同步到 `172.20.14.23/26/27/33`。确认公网域名 `https://guide.sznhmuseum.org.cn` 正常,静态讲解数据 `guide-contents=720`、`exhibits=4700`、`manifest.sourceHost=172.20.14.21`。 +- 2026-07-13:确认 `pnpm build:h5` 在 terser 阶段可能 OOM,正式部署可使用 `build:h5:no-minify` 兜底。 +- 2026-07-13:新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。 +- 2026-07-15:排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8,已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。 diff --git a/package.json b/package.json index 2b08831..5cf61e8 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "scripts": { "dev:h5": "uni -p h5 --mode development", "dev:test:h5": "uni -p h5 --mode test", - "build:test:h5": "uni build -p h5 --mode test && node scripts/copy-h5-nav-assets.cjs", - "build:h5": "uni build -p h5 --mode production && node scripts/copy-h5-nav-assets.cjs", + "build:test:h5": "uni build -p h5 --mode test && node scripts/finalize-h5-build.cjs --allow-placeholder", + "build:h5": "uni build -p h5 --mode production && node scripts/finalize-h5-build.cjs --require-tencent-map-key", + "build:h5:no-minify": "uni build -p h5 --mode production --minify false && node scripts/finalize-h5-build.cjs --require-tencent-map-key", "dev:mp-weixin": "uni -p mp-weixin --mode development", "dev:test:mp-weixin": "uni -p mp-weixin --mode test", "build:test:mp-weixin": "uni build -p mp-weixin --mode test", diff --git a/scripts/finalize-h5-build.cjs b/scripts/finalize-h5-build.cjs new file mode 100644 index 0000000..d6e5d18 --- /dev/null +++ b/scripts/finalize-h5-build.cjs @@ -0,0 +1,145 @@ +const fs = require('node:fs') +const path = require('node:path') +const { spawnSync } = require('node:child_process') + +require('./copy-h5-nav-assets.cjs') + +const projectRoot = path.resolve(__dirname, '..') +const h5Root = path.join(projectRoot, 'dist', 'build', 'h5') +const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__' +const badPatterns = [ + 'http://1.92.206.90:9000', + '1.92.206.90', + 'guide.whaoyue.com', + placeholder +] +const args = new Set(process.argv.slice(2)) +const requireTencentMapKey = args.has('--require-tencent-map-key') +const allowPlaceholder = args.has('--allow-placeholder') +const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim() + +if (!fs.existsSync(h5Root)) { + throw new Error(`H5 build output not found: ${h5Root}`) +} + +const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath) + +const walkFiles = (dir, files = []) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const filePath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + walkFiles(filePath, files) + continue + } + + if (entry.isFile()) { + files.push(filePath) + } + } + + return files +} + +const allFiles = walkFiles(h5Root) +const textFiles = allFiles.filter(isTextFile) +const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder)) +const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder + +if (placeholderFiles.length > 0) { + if (!hasUsableTencentMapKey) { + const message = [ + 'H5 build output still contains Tencent map key placeholder.', + 'Please set VITE_TENCENT_MAP_KEY before production build.' + ].join(' ') + + if (requireTencentMapKey && !allowPlaceholder) { + throw new Error(message) + } + + console.warn(`[finalize-h5-build] ${message}`) + } else { + // 所有构建产物文本替换必须由 Node 按 UTF-8 完成,避免 PowerShell 默认编码破坏中文注释或字符串。 + for (const file of placeholderFiles) { + const text = fs.readFileSync(file, 'utf8') + fs.writeFileSync(file, text.split(placeholder).join(tencentMapKey), 'utf8') + } + } +} + +const indexPath = path.join(h5Root, 'index.html') +const indexHtml = fs.readFileSync(indexPath, 'utf8') +const entryMatch = indexHtml.match(/src="\/?([^"]*assets\/index-[^"]+\.js)"/) + +if (!entryMatch) { + throw new Error(`Entry JS not found in ${indexPath}`) +} + +const entryFile = path.join(h5Root, entryMatch[1]) + +if (!fs.existsSync(entryFile)) { + throw new Error(`Entry JS file not found: ${entryFile}`) +} + +const jsFiles = allFiles.filter((file) => /\.js$/i.test(file)) + +for (const file of jsFiles) { + const check = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }) + + if (check.status !== 0) { + process.stdout.write(check.stdout || '') + process.stderr.write(check.stderr || '') + throw new Error(`JavaScript syntax check failed: ${path.relative(projectRoot, file)}`) + } +} + +const badMatches = [] + +for (const file of textFiles) { + const text = fs.readFileSync(file, 'utf8') + const matchedPattern = badPatterns.find((pattern) => text.includes(pattern)) + + if (matchedPattern) { + badMatches.push({ + file: path.relative(projectRoot, file), + pattern: matchedPattern + }) + } +} + +if (badMatches.length > 0) { + if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) { + console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.') + } else { + console.error(JSON.stringify(badMatches.slice(0, 20), null, 2)) + throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.') + } +} + +const readJson = (relativePath) => JSON.parse(fs.readFileSync(path.join(h5Root, relativePath), 'utf8')) +const manifest = readJson('static/guide-data/manifest.json') +const guideContents = readJson('static/guide-data/guide-contents.json') +const exhibits = readJson('static/guide-data/exhibits.json') +const guideStops = readJson('static/guide-data/guide-stops.json') + +const requiredFiles = [ + 'static/guide/museum-brand-icons.webp', + 'static/icons/poi/shortcut-icons.svg', + 'static/icons/marker-entrance.svg', + 'static/Fonts/HarmonyOS_SansSC_Regular.ttf' +] +const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(h5Root, file))) + +if (missingFiles.length > 0) { + throw new Error(`Required H5 static assets are missing: ${missingFiles.join(', ')}`) +} + +console.log(`H5_ENTRY=${entryMatch[1]}`) +console.log(`H5_ENTRY_SIZE=${fs.statSync(entryFile).size}`) +console.log(`H5_JS_SYNTAX_CHECKED=${jsFiles.length}`) +console.log(`H5_TENCENT_PLACEHOLDER_REPLACED_FILES=${hasUsableTencentMapKey ? placeholderFiles.length : 0}`) +console.log(`H5_BAD_STRING_FILE_COUNT=${badMatches.length}`) +console.log(`H5_MANIFEST_SOURCE_HOST=${manifest.source?.sourceHost || ''}`) +console.log(`H5_GUIDE_CONTENTS_ROWS=${guideContents.rows?.length || 0}`) +console.log(`H5_EXHIBITS_ROWS=${exhibits.rows?.length || 0}`) +console.log(`H5_GUIDE_STOPS_ROWS=${guideStops.rows?.length || 0}`)