feat(P2): 实现质量保障类5项功能(无障碍/视觉回归/通知渠道/漏洞扫描/灾备)

## 新增功能

### 1. 屏幕阅读器兼容性增强(a11y)
- 无障碍工具库:src/shared/lib/a11y.ts
- aria-live Hook:src/shared/hooks/use-aria-live.ts
- a11y 组件:skip-link/visually-hidden/focus-trap/aria-status
- 增强 UI:table.tsx 系统性 ARIA role,dialog.tsx aria-modal
- 审计文档:docs/accessibility/a11y-audit.md(WCAG 2.1 AA 清单)

### 2. 视觉回归测试
- 测试套件:tests/visual/(homepage + 3 个 dashboard)
- 3 视口(desktop/tablet/mobile)× 2 主题(light/dark)
- 动态元素遮罩,避免误报
- playwright.config.ts 新增 visual-chromium 项目
- 文档:docs/testing/visual-regression.md

### 3. 短信/微信推送渠道集成
- 新模块:src/modules/notifications/
- 4 个渠道:SMS(阿里云/腾讯云)、WeChat(公众号)、Email(SMTP)、In-App
- 分发器按用户偏好并行多渠道发送
- 外部 SDK 动态 import,Mock 模式开发可用
- 文档:docs/notifications/channels.md

### 4. 漏洞扫描 CI 集成
- CI security-scan job:npm audit + Snyk + Trivy FS + OWASP ZAP
- 独立工作流 security.yml:每周一深度扫描 + 容器镜像扫描
- 配置:suppressions.json + .trivyignore
- 本地脚本:security-scan.sh/ps1
- 文档:docs/security/scanning.md(SLA 分级)

### 5. 灾备方案
- 脚本:backup-verify/backup-offsite-sync/dr-drill/failover/health-check
- CI 增强:备份后校验+异地同步,每周灾备演练
- 独立工作流 dr-drill.yml:每周一凌晨 4 点自动演练
- 文档:docs/dr/dr-plan.md(RTO 4h/RPO 24h)+ dr-runbook.md(6 故障场景)

## 验证
- npx tsc --noEmit:0 错误
- npm run lint:0 错误 0 警告
This commit is contained in:
SpecialX
2026-06-17 20:18:29 +08:00
parent b86255f0ea
commit 6585e10c6f
53 changed files with 7491 additions and 37 deletions

369
scripts/dr-drill.sh Normal file
View File

@@ -0,0 +1,369 @@
#!/bin/bash
# 灾备演练脚本
# 用法: ./dr-drill.sh
# 自动化灾备演练:从备份恢复到测试数据库,验证数据完整性
set -u
show_help() {
cat <<EOF
用法: $0 [选项]
灾备演练脚本,自动化测试备份恢复流程
选项:
--backup FILE 指定备份文件(不指定则使用最新备份)
--test-db NAME 测试数据库名(默认 next_edu_dr_drill)
--no-cleanup 演练后不清理测试数据库
--report-dir DIR 报告输出目录(默认 docs/dr/reports)
--help, -h 显示帮助信息
环境变量:
DATABASE_URL 数据库连接 URL(必需)
BACKUP_DIR 备份目录(默认 ./backups)
DR_DRILL_TEST_DB 测试数据库名(默认 next_edu_dr_drill)
DR_DRILL_REPORT_DIR 报告目录(默认 docs/dr/reports)
退出码:
0 演练成功
1 演练失败
EOF
}
# 解析参数
BACKUP_FILE=""
NO_CLEANUP=0
REPORT_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
--help|-h)
show_help
exit 0
;;
--backup)
if [ $# -lt 2 ]; then
echo "ERROR: --backup requires an argument" >&2
exit 1
fi
BACKUP_FILE="$2"
shift 2
;;
--test-db)
if [ $# -lt 2 ]; then
echo "ERROR: --test-db requires an argument" >&2
exit 1
fi
DR_DRILL_TEST_DB="$2"
shift 2
;;
--no-cleanup)
NO_CLEANUP=1
shift
;;
--report-dir)
if [ $# -lt 2 ]; then
echo "ERROR: --report-dir requires an argument" >&2
exit 1
fi
REPORT_DIR="$2"
shift 2
;;
*)
echo "ERROR: Unknown argument: $1" >&2
exit 1
;;
esac
done
# 配置
DATABASE_URL="${DATABASE_URL:-}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
TEST_DB="${DR_DRILL_TEST_DB:-next_edu_dr_drill}"
REPORT_DIR="${REPORT_DIR:-${DR_DRILL_REPORT_DIR:-docs/dr/reports}}"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
REPORT_FILE="$REPORT_DIR/dr_drill_${TIMESTAMP}.md"
# 检查 DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "ERROR: DATABASE_URL not set" >&2
exit 1
fi
# 解析 DATABASE_URL
DB_USER=$(echo "$DATABASE_URL" | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p')
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\):.*/\1/p')
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's/.*\/\([^?]*\).*/\1/p')
# 创建报告目录
mkdir -p "$REPORT_DIR"
# 初始化报告
init_report() {
cat > "$REPORT_FILE" <<EOF
# 灾备演练报告
- **演练时间**: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
- **测试数据库**: $TEST_DB
- **源数据库**: $DB_NAME
- **数据库主机**: $DB_HOST:$DB_PORT
- **备份文件**: $BACKUP_FILE
## 演练步骤
EOF
}
# 追加报告
append_report() {
echo "$1" >> "$REPORT_FILE"
}
# 记录步骤结果
step_result() {
local step="$1"
local status="$2"
local detail="$3"
append_report "### 步骤 $step: $status"
append_report ""
append_report "$detail"
append_report ""
if [ "$status" = "FAILED" ]; then
append_report "❌ 步骤失败"
else
append_report "✅ 步骤成功"
fi
append_report ""
echo "---"
}
echo "=== Disaster Recovery Drill ==="
echo "Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "Test DB: $TEST_DB"
echo "Source DB: $DB_NAME@$DB_HOST:$DB_PORT"
echo "Report: $REPORT_FILE"
echo ""
init_report
DRILL_START=$(date +%s)
OVERALL_STATUS="SUCCESS"
# 步骤 1: 查找备份文件
echo "[1/6] Locating backup file..."
if [ -z "$BACKUP_FILE" ]; then
BACKUP_FILE=$(ls -t "$BACKUP_DIR"/db_backup_*.sql.gz 2>/dev/null | head -1)
if [ -z "$BACKUP_FILE" ]; then
echo " FAIL: No backup file found in $BACKUP_DIR"
step_result "1 - 定位备份文件" "FAILED" "未找到备份文件于 $BACKUP_DIR"
OVERALL_STATUS="FAILED"
append_report "## 演练结果: ❌ FAILED"
append_report ""
append_report "演练失败,未找到备份文件"
exit 1
fi
fi
if [ ! -f "$BACKUP_FILE" ]; then
echo " FAIL: Backup file not found: $BACKUP_FILE"
step_result "1 - 定位备份文件" "FAILED" "备份文件不存在: $BACKUP_FILE"
OVERALL_STATUS="FAILED"
append_report "## 演练结果: ❌ FAILED"
exit 1
fi
BACKUP_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || stat -f%z "$BACKUP_FILE" 2>/dev/null)
echo " PASS: Found backup: $BACKUP_FILE (${BACKUP_SIZE} bytes)"
step_result "1 - 定位备份文件" "PASSED" "备份文件: \`$BACKUP_FILE\` (${BACKUP_SIZE} bytes)"
# 步骤 2: 创建测试数据库
echo "[2/6] Creating test database..."
# 先删除已存在的测试数据库
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "DROP DATABASE IF EXISTS \`$TEST_DB\`;" 2>/dev/null
if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "CREATE DATABASE \`$TEST_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null; then
echo " PASS: Test database created: $TEST_DB"
step_result "2 - 创建测试数据库" "PASSED" "测试数据库 \`$TEST_DB\` 创建成功"
else
echo " FAIL: Could not create test database"
step_result "2 - 创建测试数据库" "FAILED" "创建测试数据库 \`$TEST_DB\` 失败"
OVERALL_STATUS="FAILED"
append_report "## 演练结果: ❌ FAILED"
exit 1
fi
# 步骤 3: 从备份恢复到测试数据库
echo "[3/6] Restoring backup to test database..."
RESTORE_START=$(date +%s)
if gunzip -c "$BACKUP_FILE" | mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$TEST_DB" 2>/dev/null; then
RESTORE_END=$(date +%s)
RESTORE_DURATION=$((RESTORE_END - RESTORE_START))
echo " PASS: Restore completed in ${RESTORE_DURATION}s"
step_result "3 - 从备份恢复" "PASSED" "恢复完成,耗时 ${RESTORE_DURATION}"
else
echo " FAIL: Restore failed"
step_result "3 - 从备份恢复" "FAILED" "从备份恢复失败"
OVERALL_STATUS="FAILED"
# 尝试清理
if [ "$NO_CLEANUP" -eq 0 ]; then
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "DROP DATABASE IF EXISTS \`$TEST_DB\`;" 2>/dev/null || true
fi
append_report "## 演练结果: ❌ FAILED"
exit 1
fi
# 步骤 4: 数据完整性检查
echo "[4/6] Running data integrity checks..."
# 获取测试数据库表数量
TEST_TABLES=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TEST_DB';" \
-s -N 2>/dev/null || echo 0)
# 获取源数据库表数量
SOURCE_TABLES=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME';" \
-s -N 2>/dev/null || echo 0)
echo " Test DB tables: $TEST_TABLES"
echo " Source DB tables: $SOURCE_TABLES"
# 获取测试数据库总记录数
TEST_RECORDS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$TEST_DB';" \
-s -N 2>/dev/null || echo 0)
# 获取源数据库总记录数
SOURCE_RECORDS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$DB_NAME';" \
-s -N 2>/dev/null || echo 0)
echo " Test DB records: $TEST_RECORDS"
echo " Source DB records: $SOURCE_RECORDS"
INTEGRITY_DETAIL="| 指标 | 测试库 | 源库 |
|------|--------|------|
| 表数量 | $TEST_TABLES | $SOURCE_TABLES |
| 记录数(近似) | $TEST_RECORDS | $SOURCE_RECORDS |"
if [ "$TEST_TABLES" -ge "$SOURCE_TABLES" ]; then
echo " PASS: Table count matches"
step_result "4 - 数据完整性检查" "PASSED" "$INTEGRITY_DETAIL"
else
echo " WARN: Test DB has fewer tables than source"
step_result "4 - 数据完整性检查" "WARN" "$INTEGRITY_DETAIL
⚠️ 测试库表数量少于源库"
fi
# 步骤 5: 冒烟测试
echo "[5/6] Running smoke tests..."
SMOKE_PASSED=0
SMOKE_FAILED=0
SMOKE_DETAIL=""
# 测试 1: 检查 users 表(如果存在)
USER_COUNT=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$TEST_DB" \
-e "SELECT COUNT(*) FROM users;" -s -N 2>/dev/null || echo "N/A")
if [ "$USER_COUNT" != "N/A" ]; then
SMOKE_PASSED=$((SMOKE_PASSED + 1))
SMOKE_DETAIL="${SMOKE_DETAIL}- ✅ users 表查询成功: ${USER_COUNT} 条记录
"
echo " PASS: users table query: $USER_COUNT records"
else
SMOKE_DETAIL="${SMOKE_DETAIL}- ⚠️ users 表不存在或查询失败
"
echo " WARN: users table not found or query failed"
fi
# 测试 2: 检查 schools 表(如果存在)
SCHOOL_COUNT=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$TEST_DB" \
-e "SELECT COUNT(*) FROM schools;" -s -N 2>/dev/null || echo "N/A")
if [ "$SCHOOL_COUNT" != "N/A" ]; then
SMOKE_PASSED=$((SMOKE_PASSED + 1))
SMOKE_DETAIL="${SMOKE_DETAIL}- ✅ schools 表查询成功: ${SCHOOL_COUNT} 条记录
"
echo " PASS: schools table query: $SCHOOL_COUNT records"
else
SMOKE_DETAIL="${SMOKE_DETAIL}- ⚠️ schools 表不存在或查询失败
"
echo " WARN: schools table not found or query failed"
fi
# 测试 3: 执行简单 JOIN 查询(检查关系完整性)
JOIN_TEST=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$TEST_DB" \
-e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TEST_DB' AND table_type='BASE TABLE';" \
-s -N 2>/dev/null || echo "0")
if [ "$JOIN_TEST" -gt 0 ]; then
SMOKE_PASSED=$((SMOKE_PASSED + 1))
SMOKE_DETAIL="${SMOKE_DETAIL}- ✅ 基础表查询成功: ${JOIN_TEST} 个基础表
"
echo " PASS: Base table query: $JOIN_TEST tables"
else
SMOKE_DETAIL="${SMOKE_DETAIL}- ❌ 基础表查询失败
"
SMOKE_FAILED=$((SMOKE_FAILED + 1))
echo " FAIL: Base table query failed"
fi
step_result "5 - 冒烟测试" "PASSED" "通过: $SMOKE_PASSED, 失败: $SMOKE_FAILED
$SMOKE_DETAIL"
# 步骤 6: 清理测试数据库
echo "[6/6] Cleaning up test database..."
if [ "$NO_CLEANUP" -eq 1 ]; then
echo " SKIP: Cleanup skipped (--no-cleanup)"
step_result "6 - 清理测试数据库" "SKIPPED" "演练后保留测试数据库 \`$TEST_DB\`"
else
if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" \
-e "DROP DATABASE IF EXISTS \`$TEST_DB\`;" 2>/dev/null; then
echo " PASS: Test database dropped: $TEST_DB"
step_result "6 - 清理测试数据库" "PASSED" "测试数据库 \`$TEST_DB\` 已删除"
else
echo " WARN: Could not drop test database (manual cleanup required)"
step_result "6 - 清理测试数据库" "WARN" "⚠️ 无法删除测试数据库 \`$TEST_DB\`,需手动清理"
fi
fi
# 生成总结
DRILL_END=$(date +%s)
DRILL_DURATION=$((DRILL_END - DRILL_START))
append_report "## 演练结果"
append_report ""
if [ "$OVERALL_STATUS" = "SUCCESS" ]; then
append_report "**状态**: ✅ 成功"
else
append_report "**状态**: ❌ 失败"
fi
append_report "**总耗时**: ${DRILL_DURATION}"
append_report "**备份文件**: \`$BACKUP_FILE\`"
append_report "**测试数据库**: \`$TEST_DB\`"
append_report ""
append_report "## RTO/RPO 评估"
append_report ""
append_report "- **RTO 目标**: 4 小时"
append_report "- **本次恢复耗时**: ${RESTORE_DURATION} 秒 ($(( RESTORE_DURATION / 60 )) 分钟)"
if [ -n "${RESTORE_DURATION:-}" ] && [ "$RESTORE_DURATION" -lt 14400 ]; then
append_report "- **RTO 评估**: ✅ 达标"
else
append_report "- **RTO 评估**: ⚠️ 需关注"
fi
append_report "- **RPO 目标**: 24 小时(取决于备份频率)"
append_report ""
echo ""
echo "=== Drill Summary ==="
echo "Status: $OVERALL_STATUS"
echo "Duration: ${DRILL_DURATION}s"
echo "Report: $REPORT_FILE"
echo ""
if [ "$OVERALL_STATUS" = "SUCCESS" ]; then
exit 0
else
exit 1
fi