feat: 初始化慧遇书院官网一期
This commit is contained in:
47
scripts/backup.sh
Executable file
47
scripts/backup.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
backup_kind="${1:-daily}"
|
||||
case "$backup_kind" in daily|weekly|monthly) ;; *) echo "用法: BACKUP_ENCRYPTION_PASSWORD=... sh scripts/backup.sh [daily|weekly|monthly]" >&2; exit 2 ;; esac
|
||||
|
||||
backup_password="${BACKUP_ENCRYPTION_PASSWORD:-}"
|
||||
if [ "${#backup_password}" -lt 20 ]; then
|
||||
echo "BACKUP_ENCRYPTION_PASSWORD 至少需要 20 个字符" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)-$$"
|
||||
archive_name="huiyu-${backup_kind}-${timestamp}.tar.gz.enc"
|
||||
work_dir="/app/data/backups/work-${timestamp}"
|
||||
backup_dir="${BACKUP_HOST_DIR:-./backups}"
|
||||
case "$backup_dir" in ''|'/'|'.'|'..') echo "BACKUP_HOST_DIR 不能是空目录、根目录或当前目录" >&2; exit 2 ;; esac
|
||||
|
||||
docker compose exec -T \
|
||||
-e BACKUP_WORK_DIR="$work_dir" \
|
||||
-e BACKUP_ARCHIVE_NAME="$archive_name" \
|
||||
-e BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" \
|
||||
web sh -eu -c '
|
||||
mkdir -p "$BACKUP_WORK_DIR" /app/offsite-backups
|
||||
node -e "
|
||||
const Database = require(\"better-sqlite3\");
|
||||
const db = new Database(\"/app/data/db/huiyu.sqlite\");
|
||||
db.backup(process.env.BACKUP_WORK_DIR + \"/huiyu.sqlite\")
|
||||
.then(() => db.close())
|
||||
.catch(error => { console.error(error); db.close(); process.exit(1); });
|
||||
"
|
||||
cp -R /app/data/uploads "$BACKUP_WORK_DIR/uploads"
|
||||
tar -czf "$BACKUP_WORK_DIR/bundle.tar.gz" -C "$BACKUP_WORK_DIR" huiyu.sqlite uploads
|
||||
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
|
||||
-pass env:BACKUP_ENCRYPTION_PASSWORD \
|
||||
-in "$BACKUP_WORK_DIR/bundle.tar.gz" \
|
||||
-out "/app/offsite-backups/$BACKUP_ARCHIVE_NAME"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:BACKUP_ENCRYPTION_PASSWORD \
|
||||
-in "/app/offsite-backups/$BACKUP_ARCHIVE_NAME" | tar -tzf - >/dev/null
|
||||
rm -rf "$BACKUP_WORK_DIR"
|
||||
'
|
||||
|
||||
find "$backup_dir" -type f -name 'huiyu-daily-*.tar.gz.enc' -mtime +7 -delete
|
||||
find "$backup_dir" -type f -name 'huiyu-weekly-*.tar.gz.enc' -mtime +28 -delete
|
||||
find "$backup_dir" -type f -name 'huiyu-monthly-*.tar.gz.enc' -mtime +186 -delete
|
||||
echo "备份完成并校验: $backup_dir/$archive_name"
|
||||
28
scripts/purge-media-trash.sh
Executable file
28
scripts/purge-media-trash.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
retention_days="${MEDIA_TRASH_RETENTION_DAYS:-30}"
|
||||
case "$retention_days" in *[!0-9]*|'') echo "MEDIA_TRASH_RETENTION_DAYS 必须是非负整数" >&2; exit 2 ;; esac
|
||||
|
||||
docker compose exec -T -e RETENTION_DAYS="$retention_days" web node - <<'NODE'
|
||||
const Database = require('better-sqlite3')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const db = new Database('/app/data/db/huiyu.sqlite')
|
||||
const cutoff = new Date(Date.now() - Number(process.env.RETENTION_DAYS) * 86400000).toISOString()
|
||||
const rows = db.prepare("SELECT id, asset_key, original_path, derived_path, thumbnail_path FROM media_assets WHERE status = 'trash' AND deleted_at <= ?").all(cutoff)
|
||||
const root = path.resolve('/app/data/uploads/trash')
|
||||
const remove = relative => {
|
||||
const target = path.resolve(root, relative)
|
||||
if (!target.startsWith(root + path.sep)) throw new Error('unsafe media path')
|
||||
try { fs.unlinkSync(target) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
}
|
||||
const finish = db.transaction(row => {
|
||||
for (const relative of [row.original_path, row.derived_path, row.thumbnail_path]) remove(relative)
|
||||
db.prepare("UPDATE media_assets SET status = 'deleted', updated_at = ? WHERE id = ?").run(new Date().toISOString(), row.id)
|
||||
db.prepare("INSERT INTO audit_logs (action, entity_type, entity_key, metadata_json, created_at) VALUES ('media.purge', 'media', ?, '{}', ?)").run(row.asset_key, new Date().toISOString())
|
||||
})
|
||||
for (const row of rows) finish(row)
|
||||
db.close()
|
||||
console.log(`已物理清理 ${rows.length} 个回收站素材`)
|
||||
NODE
|
||||
42
scripts/restore.sh
Executable file
42
scripts/restore.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
archive="${1:-}"
|
||||
if [ -z "$archive" ] || [ ! -f "$archive" ]; then echo "用法: CONFIRM_RESTORE=yes BACKUP_ENCRYPTION_PASSWORD=... sh scripts/restore.sh /绝对路径/备份.enc" >&2; exit 2; fi
|
||||
if [ "${CONFIRM_RESTORE:-}" != "yes" ]; then echo "恢复会覆盖当前数据库和上传素材,请设置 CONFIRM_RESTORE=yes" >&2; exit 2; fi
|
||||
backup_password="${BACKUP_ENCRYPTION_PASSWORD:-}"
|
||||
if [ "${#backup_password}" -lt 20 ]; then echo "BACKUP_ENCRYPTION_PASSWORD 至少需要 20 个字符" >&2; exit 2; fi
|
||||
|
||||
archive_dir="$(cd "$(dirname "$archive")" && pwd)"
|
||||
archive_name="$(basename "$archive")"
|
||||
restore_id="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
echo "停止应用并保留恢复前备份…"
|
||||
BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" sh scripts/backup.sh daily
|
||||
docker compose stop web
|
||||
|
||||
cleanup() { docker compose start web >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose run --rm --no-deps \
|
||||
-v "$archive_dir:/restore-source:ro" \
|
||||
-e RESTORE_ARCHIVE="/restore-source/$archive_name" \
|
||||
-e RESTORE_ID="$restore_id" \
|
||||
-e BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" \
|
||||
web sh -eu -c '
|
||||
restore_dir="/app/data/restore-$RESTORE_ID"
|
||||
mkdir -p "$restore_dir"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:BACKUP_ENCRYPTION_PASSWORD -in "$RESTORE_ARCHIVE" | tar -xzf - -C "$restore_dir"
|
||||
test -f "$restore_dir/huiyu.sqlite"
|
||||
node -e "const Database=require(\"better-sqlite3\"); const db=new Database(process.argv[1],{readonly:true}); const result=db.pragma(\"integrity_check\",{simple:true}); db.close(); if(result!==\"ok\") process.exit(1)" "$restore_dir/huiyu.sqlite"
|
||||
rm -f /app/data/db/huiyu.sqlite /app/data/db/huiyu.sqlite-wal /app/data/db/huiyu.sqlite-shm
|
||||
cp "$restore_dir/huiyu.sqlite" /app/data/db/huiyu.sqlite
|
||||
rm -rf /app/data/uploads
|
||||
cp -R "$restore_dir/uploads" /app/data/uploads
|
||||
rm -rf "$restore_dir"
|
||||
'
|
||||
|
||||
docker compose start web
|
||||
trap - EXIT INT TERM
|
||||
echo "恢复完成。请执行: docker compose ps && curl -fsS http://localhost:3000/api/health"
|
||||
31
scripts/seed-mock-content.mjs
Normal file
31
scripts/seed-mock-content.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const baseUrl = (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, '')
|
||||
const username = process.env.ADMIN_USERNAME || 'admin'
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
if (!password) throw new Error('请通过 ADMIN_PASSWORD 环境变量提供后台密码')
|
||||
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!loginResponse.ok) throw new Error(`后台登录失败: ${loginResponse.status}`)
|
||||
const cookie = loginResponse.headers.get('set-cookie')?.split(';')[0]
|
||||
if (!cookie) throw new Error('后台登录未返回会话 Cookie')
|
||||
|
||||
const headers = { Cookie: cookie }
|
||||
const currentResponse = await fetch(`${baseUrl}/api/admin/site`, { headers })
|
||||
if (!currentResponse.ok) throw new Error(`读取后台草稿失败: ${currentResponse.status}`)
|
||||
const current = (await currentResponse.json()).data
|
||||
const mock = JSON.parse(await readFile(new URL('../content/演示数据/一期官网演示数据.json', import.meta.url), 'utf8'))
|
||||
const document = { ...current, ...mock, hero: { ...current.hero, titleLine1: '身心疗愈', titleLine2: '科技赋能', subtitle: '用科技与专业,陪伴每一次心灵的成长', cta: '了解慧遇书院' } }
|
||||
|
||||
const saveResponse = await fetch(`${baseUrl}/api/admin/site`, {
|
||||
method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(document),
|
||||
})
|
||||
if (!saveResponse.ok) throw new Error(`保存演示草稿失败: ${saveResponse.status} ${await saveResponse.text()}`)
|
||||
const publishResponse = await fetch(`${baseUrl}/api/admin/site/publish`, { method: 'POST', headers })
|
||||
if (!publishResponse.ok) throw new Error(`发布演示数据失败: ${publishResponse.status} ${await publishResponse.text()}`)
|
||||
const result = await publishResponse.json()
|
||||
console.log(`演示数据已写入后台并发布为 V${result.version}`)
|
||||
36
scripts/smoke-test.mjs
Normal file
36
scripts/smoke-test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const baseUrl = (process.env.BASE_URL || 'http://localhost:3000').replace(/\/$/, '')
|
||||
|
||||
async function get(path) {
|
||||
const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual' })
|
||||
assert.equal(response.status, 200, `${path} returned ${response.status}`)
|
||||
return response
|
||||
}
|
||||
|
||||
const health = await get('/api/health')
|
||||
const healthBody = await health.json()
|
||||
assert.equal(healthBody.status, 'ok')
|
||||
assert.equal(healthBody.checks.database, 'ok')
|
||||
assert.equal(healthBody.checks.uploads, 'writable')
|
||||
|
||||
const homepage = await get('/')
|
||||
assert.equal(homepage.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.equal(homepage.headers.get('x-frame-options'), 'DENY')
|
||||
assert.match(homepage.headers.get('content-security-policy') || '', /frame-ancestors 'none'/)
|
||||
const homepageHtml = await homepage.text()
|
||||
assert.match(homepageHtml, /application\/ld\+json/)
|
||||
|
||||
const sitemap = await get('/sitemap.xml')
|
||||
const sitemapXml = await sitemap.text()
|
||||
const urls = [...sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => new URL(match[1]).pathname)
|
||||
assert.ok(urls.length >= 5, 'sitemap should contain public routes')
|
||||
for (const path of urls) await get(path)
|
||||
|
||||
const robots = await get('/robots.txt')
|
||||
assert.match(await robots.text(), /Disallow: \/admin/)
|
||||
|
||||
const hero = await get('/images/hero-bg-v3.webp')
|
||||
assert.match(hero.headers.get('cache-control') || '', /immutable/)
|
||||
|
||||
console.log(`Smoke test passed: ${urls.length} sitemap routes, health, headers, robots and static cache.`)
|
||||
Reference in New Issue
Block a user