初始化兜爸心选 App 官网项目(构建与发布)
- src/:从线上 shop.douba.top/public/app 拉取的页面源码(v0.3.8) - 版本号、更新日期、安装包大小改为模板变量,统一由 site.config.json 管理, 安装包大小在构建时从 APK 实际字节数计算 - scripts/build.mjs:构建到 dist/,校验 APK、模板变量与资源引用 - scripts/deploy.mjs:与线上比对差异 → 上传 → 备份 → 原子切换 → 线上验证 - scripts/rollback.mjs、scripts/preview.mjs:回滚与本地预览 - AGENTS.md:项目说明、发版流程与服务器/nginx 约定 由 AI 助手协助完成。
This commit is contained in:
117
scripts/build.mjs
Normal file
117
scripts/build.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// 构建:src/ → dist/,渲染 {{变量}},拷入 APK,并校验页面引用的本地资源都存在
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const RELEASES = path.join(ROOT, 'releases');
|
||||
|
||||
export function loadConfig() {
|
||||
return JSON.parse(fs.readFileSync(path.join(ROOT, 'site.config.json'), 'utf8'));
|
||||
}
|
||||
|
||||
export function apkFileName(cfg) {
|
||||
return `douba-v${cfg.version}.apk`;
|
||||
}
|
||||
|
||||
// 2026-10-29 → 2026 年 10 月 29 日
|
||||
function cnDate(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
|
||||
if (!m) throw new Error(`日期格式应为 YYYY-MM-DD:${iso}`);
|
||||
return `${m[1]} 年 ${Number(m[2])} 月 ${Number(m[3])} 日`;
|
||||
}
|
||||
|
||||
// 字节 → "61.5 MB"(十进制,和手机系统显示一致)
|
||||
function mb(bytes) {
|
||||
return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
});
|
||||
}
|
||||
|
||||
function render(text, vars, file) {
|
||||
return text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
||||
if (!(key in vars)) throw new Error(`${file}: 未定义的模板变量 {{${key}}}`);
|
||||
return vars[key];
|
||||
});
|
||||
}
|
||||
|
||||
// 找出 HTML 的 src/href 和 CSS 的 url(),确认指向 /app 内的文件都存在
|
||||
function checkRefs(files) {
|
||||
const missing = [];
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file);
|
||||
if (ext !== '.html' && ext !== '.css') continue;
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const re = ext === '.html' ? /\s(?:src|href|content)="([^"]+)"/g : /url\(["']?([^"')]+)["']?\)/g;
|
||||
for (let [, raw] of text.matchAll(re)) {
|
||||
raw = raw.replace(/^https:\/\/shop\.douba\.top(?=\/app)/, ''); // og:image 等写的是本站完整地址
|
||||
if (/\s/.test(raw) || (!raw.includes('/') && !raw.includes('.'))) continue; // content 里的普通文本
|
||||
const ref = raw.split(/[?#]/)[0];
|
||||
if (!ref || /^(https?:|data:|mailto:|tel:|\/\/)/.test(raw) || /^(#|%23)/.test(raw)) continue; // %23 = 内联 SVG 里的 url(#id)
|
||||
let target;
|
||||
if (ref.startsWith('/app/')) target = path.join(DIST, ref.slice(5));
|
||||
else if (ref === '/app' || ref === '/app/') target = path.join(DIST, 'index.html');
|
||||
else if (ref.startsWith('/')) continue; // 站点根下的商城页面(如 /protocol.html),不归本项目管
|
||||
else target = path.join(path.dirname(file), ref);
|
||||
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
||||
if (!fs.existsSync(target)) missing.push(`${path.relative(DIST, file)} → ${raw}`);
|
||||
}
|
||||
}
|
||||
if (missing.length) throw new Error(`以下引用的文件不存在:\n ${missing.join('\n ')}`);
|
||||
}
|
||||
|
||||
export function build() {
|
||||
const cfg = loadConfig();
|
||||
const apkFile = apkFileName(cfg);
|
||||
const apkSrc = path.join(RELEASES, apkFile);
|
||||
if (!fs.existsSync(apkSrc)) {
|
||||
throw new Error(`找不到安装包 releases/${apkFile}(site.config.json 的 version 是 ${cfg.version})`);
|
||||
}
|
||||
const apkBytes = fs.statSync(apkSrc).size;
|
||||
|
||||
const vars = {
|
||||
version: cfg.version,
|
||||
channel: cfg.channel,
|
||||
releaseDate: cfg.releaseDate,
|
||||
screenshotVersion: cfg.screenshotVersion,
|
||||
'permissions.version': cfg.permissions.version,
|
||||
'permissions.updatedCn': cnDate(cfg.permissions.updated),
|
||||
apkFile,
|
||||
apkSize: mb(apkBytes),
|
||||
};
|
||||
cnDate(cfg.releaseDate); // 只做格式校验
|
||||
|
||||
fs.rmSync(DIST, { recursive: true, force: true });
|
||||
for (const file of walk(SRC)) {
|
||||
const out = path.join(DIST, path.relative(SRC, file));
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
if (path.extname(file) === '.html') {
|
||||
fs.writeFileSync(out, render(fs.readFileSync(file, 'utf8'), vars, path.relative(ROOT, file)));
|
||||
} else {
|
||||
fs.copyFileSync(file, out);
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(path.join(DIST, 'download'), { recursive: true });
|
||||
fs.copyFileSync(apkSrc, path.join(DIST, 'download', apkFile));
|
||||
|
||||
checkRefs(walk(DIST));
|
||||
return { cfg, apkFile, apkBytes, vars };
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const { cfg, apkFile, vars } = build();
|
||||
console.log(`构建完成 → dist/`);
|
||||
console.log(` 版本 v${cfg.version}(${cfg.channel}) · ${apkFile} · ${vars.apkSize} · 更新时间 ${cfg.releaseDate}`);
|
||||
} catch (e) {
|
||||
console.error(`构建失败:${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
136
scripts/deploy.mjs
Normal file
136
scripts/deploy.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
// 发布:构建 → 对比线上差异 → 上传 → 备份线上目录 → 切换 → 线上验证
|
||||
// node scripts/deploy.mjs 正式发布
|
||||
// node scripts/deploy.mjs --dry-run 只构建并列出差异,不改服务器
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from './build.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 << 20, ...opts });
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`${cmd} ${args.slice(0, 2).join(' ')} 失败(退出码 ${r.status})\n${r.stderr || ''}`);
|
||||
}
|
||||
return r.stdout;
|
||||
}
|
||||
const ssh = (host, script) => run('ssh', ['-o', 'BatchMode=yes', host, 'bash -s'], { input: `set -euo pipefail\n${script}` });
|
||||
|
||||
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
|
||||
function walk(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
});
|
||||
}
|
||||
|
||||
// 北京时间时间戳,用作备份目录名
|
||||
function stamp() {
|
||||
const d = new Date(Date.now() + 8 * 3600e3).toISOString();
|
||||
return d.slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cfg, apkFile, apkBytes, vars } = build();
|
||||
const { host, siteDir, publicUrl, keepBackups } = cfg.deploy;
|
||||
const live = `${siteDir}/public/app`;
|
||||
const deployDir = `${siteDir}/.deploy`; // 在 public/ 之外,不会被公网访问
|
||||
const ts = stamp();
|
||||
console.log(`构建完成:v${cfg.version}(${cfg.channel}) · ${apkFile} · ${vars.apkSize}`);
|
||||
|
||||
// ---- 对比线上(APK 单独比较,download/ 里的其他旧包不参与) ----
|
||||
const local = new Map(
|
||||
walk(DIST)
|
||||
.filter((f) => !f.startsWith(path.join(DIST, 'download') + path.sep))
|
||||
.map((f) => [path.relative(DIST, f), sha256(f)]),
|
||||
);
|
||||
const remoteOut = ssh(host, `
|
||||
cd ${live}
|
||||
find . -type f ! -path './download/*' -exec sha256sum {} + | sed 's| \\./| |'
|
||||
echo "__APK__ $(sha256sum download/${apkFile} 2>/dev/null | cut -d' ' -f1)"
|
||||
`);
|
||||
const remote = new Map();
|
||||
let remoteApkSha = '';
|
||||
for (const line of remoteOut.trim().split('\n')) {
|
||||
if (line.startsWith('__APK__')) remoteApkSha = line.slice(8).trim();
|
||||
else if (line) {
|
||||
const [sum, file] = line.split(/\s{2}/);
|
||||
remote.set(file, sum);
|
||||
}
|
||||
}
|
||||
const added = [...local.keys()].filter((f) => !remote.has(f));
|
||||
const changed = [...local.keys()].filter((f) => remote.has(f) && remote.get(f) !== local.get(f));
|
||||
const removed = [...remote.keys()].filter((f) => !local.has(f));
|
||||
const localApkSha = sha256(path.join(DIST, 'download', apkFile));
|
||||
const uploadApk = remoteApkSha !== localApkSha;
|
||||
|
||||
console.log('\n与线上的差异:');
|
||||
for (const f of added) console.log(` + ${f}`);
|
||||
for (const f of changed) console.log(` ~ ${f}`);
|
||||
for (const f of removed) console.log(` - ${f}`);
|
||||
console.log(` APK ${apkFile}:${uploadApk ? (remoteApkSha ? '线上不同,需上传' : '线上没有,需上传') : '线上已是同一文件,跳过上传'}`);
|
||||
if (!added.length && !changed.length && !removed.length && !uploadApk) {
|
||||
console.log('\n线上已经是最新,无需发布。');
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log('\n--dry-run:未改动服务器。');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
const tgz = path.join(os.tmpdir(), `douba-app-${ts}.tgz`);
|
||||
run('tar', ['czf', tgz, '-C', DIST, '--exclude=./download', '.']);
|
||||
const remoteTgz = `/tmp/douba-app-${ts}.tgz`;
|
||||
const remoteApk = `/tmp/douba-app-${ts}.apk`;
|
||||
console.log('\n上传站点文件…');
|
||||
run('scp', ['-q', '-o', 'BatchMode=yes', tgz, `${host}:${remoteTgz}`]);
|
||||
fs.rmSync(tgz);
|
||||
if (uploadApk) {
|
||||
console.log(`上传 ${apkFile}(${vars.apkSize},需要一会儿)…`);
|
||||
run('scp', ['-q', '-o', 'BatchMode=yes', path.join(DIST, 'download', apkFile), `${host}:${remoteApk}`], { stdio: ['ignore', 'inherit', 'pipe'] });
|
||||
}
|
||||
|
||||
// ---- 服务器端:组装新目录 → 备份旧目录 → 切换 ----
|
||||
// 新目录与线上同在一个分区,两次 mv 都是瞬间完成的重命名
|
||||
console.log('切换线上目录…');
|
||||
ssh(host, `
|
||||
stage=${deployDir}/stage-${ts}
|
||||
mkdir -p "$stage/download" ${deployDir}/backups
|
||||
tar xzf ${remoteTgz} -C "$stage"
|
||||
# 保留线上已有的 APK(硬链接,不占额外空间),旧版下载链接不会失效
|
||||
for f in ${live}/download/*.apk; do [ -e "$f" ] && ln "$f" "$stage/download/"; done
|
||||
${uploadApk ? `mv -f ${remoteApk} "$stage/download/${apkFile}"` : ':'}
|
||||
[ "$(sha256sum "$stage/download/${apkFile}" | cut -d' ' -f1)" = "${localApkSha}" ] || { echo "APK 校验失败" >&2; exit 1; }
|
||||
chown -R root:root "$stage"
|
||||
find "$stage" -type d -exec chmod 755 {} +
|
||||
find "$stage" -type f -exec chmod 644 {} +
|
||||
mv ${live} ${deployDir}/backups/app-${ts}
|
||||
mv "$stage" ${live}
|
||||
rm -f ${remoteTgz}
|
||||
ls -1d ${deployDir}/backups/app-* | sort -r | tail -n +${keepBackups + 1} | xargs -r rm -rf
|
||||
`);
|
||||
|
||||
// ---- 线上验证 ----
|
||||
console.log('验证线上…');
|
||||
const page = run('curl', ['-fsS', '-H', 'Cache-Control: no-cache', publicUrl]);
|
||||
if (!page.includes(`"softwareVersion": "${cfg.version}"`)) throw new Error(`${publicUrl} 返回的页面不是 v${cfg.version}`);
|
||||
const head = run('curl', ['-fsSI', `${publicUrl}/download/${apkFile}`]);
|
||||
const len = Number((/content-length:\s*(\d+)/i.exec(head) || [])[1]);
|
||||
if (len !== apkBytes) throw new Error(`APK 下载地址返回的大小 ${len} 与本地 ${apkBytes} 不一致`);
|
||||
|
||||
console.log(`\n发布成功:${publicUrl} (v${cfg.version},备份 ${deployDir}/backups/app-${ts})`);
|
||||
console.log('如需回滚:npm run rollback');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`\n发布失败:${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
28
scripts/preview.mjs
Normal file
28
scripts/preview.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
// 本地预览 dist/:按线上路径挂在 /app 下(/app 和 /app/ 都返回首页)
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist');
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.apk': 'application/vnd.android.package-archive',
|
||||
};
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (url === '/') { res.writeHead(302, { Location: '/app' }); return res.end(); }
|
||||
if (url !== '/app' && !url.startsWith('/app/')) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
return res.end(`本地预览只包含 /app,${url} 属于线上商城`);
|
||||
}
|
||||
let file = path.join(DIST, url.slice(4));
|
||||
if (!file.startsWith(DIST)) { res.writeHead(403); return res.end(); }
|
||||
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
|
||||
if (!fs.existsSync(file)) { res.writeHead(404); return res.end('Not Found'); }
|
||||
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream' });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}).listen(PORT, () => console.log(`本地预览:http://localhost:${PORT}/app`));
|
||||
27
scripts/rollback.mjs
Normal file
27
scripts/rollback.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
// 回滚:把线上目录换回最近一次发布前的备份
|
||||
// node scripts/rollback.mjs 回滚到最近的备份
|
||||
// node scripts/rollback.mjs --list 列出可用备份
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { loadConfig } from './build.mjs';
|
||||
|
||||
const { host, siteDir, publicUrl } = loadConfig().deploy;
|
||||
const live = `${siteDir}/public/app`;
|
||||
const backups = `${siteDir}/.deploy/backups`;
|
||||
const list = process.argv.includes('--list');
|
||||
|
||||
const script = list
|
||||
? `ls -1d ${backups}/app-* 2>/dev/null | sort -r || echo "(没有备份)"`
|
||||
: `
|
||||
latest=$(ls -1d ${backups}/app-* 2>/dev/null | sort -r | head -1)
|
||||
[ -n "$latest" ] || { echo "没有可回滚的备份" >&2; exit 1; }
|
||||
mv ${live} ${siteDir}/.deploy/rolled-back-$(date +%Y%m%d-%H%M%S)
|
||||
mv "$latest" ${live}
|
||||
echo "已回滚到 $latest"
|
||||
`;
|
||||
|
||||
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', host, 'bash -s'], {
|
||||
input: `set -euo pipefail\n${script}`,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
if (r.status !== 0) process.exit(r.status ?? 1);
|
||||
if (!list) console.log(`请打开 ${publicUrl} 确认。`);
|
||||
Reference in New Issue
Block a user