feat: 初始化慧遇书院官网一期

This commit is contained in:
2026-08-12 18:01:17 +08:00
commit 5d31ee5743
101 changed files with 17259 additions and 0 deletions

47
scripts/backup.sh Executable file
View 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
View 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
View 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"

View 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
View 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.`)