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:
420
scripts/dr-drill.ps1
Normal file
420
scripts/dr-drill.ps1
Normal file
@@ -0,0 +1,420 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
灾备演练脚本(Windows PowerShell 版本)
|
||||
.DESCRIPTION
|
||||
自动化灾备演练:从备份恢复到测试数据库,验证数据完整性
|
||||
.EXAMPLE
|
||||
.\dr-drill.ps1
|
||||
.EXAMPLE
|
||||
.\dr-drill.ps1 -BackupFile "backups\db_backup_20260617_020000.sql.gz" -TestDb "next_edu_dr_drill"
|
||||
.EXAMPLE
|
||||
.\dr-drill.ps1 -NoCleanup
|
||||
.PARAMETER BackupFile
|
||||
指定备份文件(不指定则使用最新备份)
|
||||
.PARAMETER TestDb
|
||||
测试数据库名(默认 next_edu_dr_drill)
|
||||
.PARAMETER NoCleanup
|
||||
演练后不清理测试数据库
|
||||
.PARAMETER ReportDir
|
||||
报告输出目录(默认 docs\dr\reports)
|
||||
.PARAMETER Help
|
||||
显示帮助信息
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$BackupFile = "",
|
||||
|
||||
[Parameter()]
|
||||
[string]$TestDb = "",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$NoCleanup,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ReportDir = "",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
# 显示帮助
|
||||
if ($Help) {
|
||||
Get-Help $MyInvocation.MyCommand.Path -Detailed
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 配置
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$DatabaseUrl = $env:DATABASE_URL
|
||||
if ([string]::IsNullOrEmpty($DatabaseUrl)) {
|
||||
Write-Host "ERROR: DATABASE_URL not set" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$BackupDir = if ($env:BACKUP_DIR) { $env:BACKUP_DIR } else { ".\backups" }
|
||||
if ([string]::IsNullOrEmpty($TestDb)) {
|
||||
$TestDb = if ($env:DR_DRILL_TEST_DB) { $env:DR_DRILL_TEST_DB } else { "next_edu_dr_drill" }
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($ReportDir)) {
|
||||
$ReportDir = if ($env:DR_DRILL_REPORT_DIR) { $env:DR_DRILL_REPORT_DIR } else { "docs\dr\reports" }
|
||||
}
|
||||
|
||||
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$ReportFile = Join-Path $ReportDir "dr_drill_${Timestamp}.md"
|
||||
|
||||
# 解析 DATABASE_URL
|
||||
# 格式: mysql://user:password@host:port/dbname
|
||||
function Parse-DatabaseUrl {
|
||||
param([string]$Url)
|
||||
|
||||
try {
|
||||
$uri = [System.Uri]$Url
|
||||
$userInfo = $uri.UserInfo -split ':', 2
|
||||
return @{
|
||||
User = $userInfo[0]
|
||||
Pass = if ($userInfo.Length -gt 1) { $userInfo[1] } else { "" }
|
||||
Host = $uri.Host
|
||||
Port = if ($uri.Port -gt 0) { $uri.Port } else { 3306 }
|
||||
DbName = $uri.AbsolutePath.TrimStart('/')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERROR: Invalid DATABASE_URL format" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$db = Parse-DatabaseUrl $DatabaseUrl
|
||||
|
||||
# 创建报告目录
|
||||
if (-not (Test-Path $ReportDir)) {
|
||||
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# 初始化报告
|
||||
function Init-Report {
|
||||
$content = @"
|
||||
# 灾备演练报告
|
||||
|
||||
- **演练时间**: $(Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
|
||||
- **测试数据库**: $TestDb
|
||||
- **源数据库**: $($db.DbName)
|
||||
- **数据库主机**: $($db.Host):$($db.Port)
|
||||
- **备份文件**: $BackupFile
|
||||
|
||||
## 演练步骤
|
||||
|
||||
"@
|
||||
Set-Content -Path $ReportFile -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function Append-Report {
|
||||
param([string]$Content)
|
||||
Add-Content -Path $ReportFile -Value $Content -Encoding UTF8
|
||||
}
|
||||
|
||||
function Step-Result {
|
||||
param(
|
||||
[string]$Step,
|
||||
[string]$Status,
|
||||
[string]$Detail
|
||||
)
|
||||
Append-Report "### 步骤 $Step`: $Status"
|
||||
Append-Report ""
|
||||
Append-Report $Detail
|
||||
Append-Report ""
|
||||
if ($Status -eq "FAILED") {
|
||||
Append-Report "❌ 步骤失败"
|
||||
}
|
||||
else {
|
||||
Append-Report "✅ 步骤成功"
|
||||
}
|
||||
Append-Report ""
|
||||
Write-Host "---"
|
||||
}
|
||||
|
||||
# MySQL 执行函数
|
||||
function Invoke-MySql {
|
||||
param(
|
||||
[string]$Query,
|
||||
[string]$Database = "",
|
||||
[switch]$Silent,
|
||||
[switch]$Scalar
|
||||
)
|
||||
|
||||
$mysqlArgs = @("-h", $db.Host, "-P", $db.Port, "-u", $db.User, "-p$($db.Pass)")
|
||||
if (-not [string]::IsNullOrEmpty($Database)) {
|
||||
$mysqlArgs += $Database
|
||||
}
|
||||
$mysqlArgs += @("-e", $Query)
|
||||
|
||||
if ($Scalar) {
|
||||
$mysqlArgs += @("-s", "-N")
|
||||
}
|
||||
|
||||
if ($Silent) {
|
||||
$result = & mysql @mysqlArgs 2>$null
|
||||
}
|
||||
else {
|
||||
$result = & mysql @mysqlArgs 2>&1
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
# 检查 mysql 命令
|
||||
if (-not (Get-Command mysql -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "ERROR: mysql client not found in PATH" -ForegroundColor Red
|
||||
Write-Host "Please install MySQL client tools" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "=== Disaster Recovery Drill ===" -ForegroundColor Cyan
|
||||
Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')"
|
||||
Write-Host "Test DB: $TestDb"
|
||||
Write-Host "Source DB: $($db.DbName)@$($db.Host):$($db.Port)"
|
||||
Write-Host "Report: $ReportFile"
|
||||
Write-Host ""
|
||||
|
||||
Init-Report
|
||||
$drillStart = Get-Date
|
||||
$overallStatus = "SUCCESS"
|
||||
|
||||
# 步骤 1: 查找备份文件
|
||||
Write-Host "[1/6] Locating backup file..."
|
||||
if ([string]::IsNullOrEmpty($BackupFile)) {
|
||||
$backupPattern = Join-Path $BackupDir "db_backup_*.sql.gz"
|
||||
$latestBackup = Get-ChildItem -Path $backupPattern -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestBackup) {
|
||||
$BackupFile = $latestBackup.FullName
|
||||
}
|
||||
else {
|
||||
Write-Host " FAIL: No backup file found in $BackupDir" -ForegroundColor Red
|
||||
Step-Result "1 - 定位备份文件" "FAILED" "未找到备份文件于 $BackupDir"
|
||||
Append-Report "## 演练结果: ❌ FAILED`n`n演练失败,未找到备份文件"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $BackupFile)) {
|
||||
Write-Host " FAIL: Backup file not found: $BackupFile" -ForegroundColor Red
|
||||
Step-Result "1 - 定位备份文件" "FAILED" "备份文件不存在: $BackupFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$backupSize = (Get-Item $BackupFile).Length
|
||||
Write-Host " PASS: Found backup: $BackupFile ($backupSize bytes)" -ForegroundColor Green
|
||||
Step-Result "1 - 定位备份文件" "PASSED" "备份文件: ``$BackupFile`` ($backupSize bytes)"
|
||||
|
||||
# 步骤 2: 创建测试数据库
|
||||
Write-Host "[2/6] Creating test database..."
|
||||
try {
|
||||
Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent
|
||||
Invoke-MySql -Query "CREATE DATABASE ``$TestDb`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -Silent
|
||||
Write-Host " PASS: Test database created: $TestDb" -ForegroundColor Green
|
||||
Step-Result "2 - 创建测试数据库" "PASSED" "测试数据库 ``$TestDb`` 创建成功"
|
||||
}
|
||||
catch {
|
||||
Write-Host " FAIL: Could not create test database" -ForegroundColor Red
|
||||
Step-Result "2 - 创建测试数据库" "FAILED" "创建测试数据库 ``$TestDb`` 失败"
|
||||
$overallStatus = "FAILED"
|
||||
Append-Report "## 演练结果: ❌ FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 步骤 3: 从备份恢复到测试数据库
|
||||
Write-Host "[3/6] Restoring backup to test database..."
|
||||
$restoreStart = Get-Date
|
||||
try {
|
||||
# 使用 7z 或 gunzip 解压,然后管道到 mysql
|
||||
# Windows 上可能需要使用 7z 或 .NET GZipStream
|
||||
$tempSqlFile = [System.IO.Path]::GetTempFileName()
|
||||
|
||||
# 尝试使用 gunzip(如果可用)
|
||||
if (Get-Command gunzip -ErrorAction SilentlyContinue) {
|
||||
$process = Start-Process -FilePath "gunzip" -ArgumentList "-c", "`"$BackupFile`"" -NoNewWindow -RedirectStandardOutput $tempSqlFile -Wait -PassThru
|
||||
}
|
||||
# 尝试使用 7z
|
||||
elseif (Get-Command 7z -ErrorAction SilentlyContinue) {
|
||||
& 7z e -so "$BackupFile" | Out-File -FilePath $tempSqlFile -Encoding ASCII
|
||||
}
|
||||
# 使用 .NET GZipStream
|
||||
else {
|
||||
$inStream = [System.IO.File]::OpenRead($BackupFile)
|
||||
$gzStream = New-Object System.IO.Compression.GZipStream($inStream, [System.IO.Compression.CompressionMode]::Decompress)
|
||||
$reader = New-Object System.IO.StreamReader($gzStream, [System.Text.Encoding]::UTF8)
|
||||
$content = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
$gzStream.Close()
|
||||
$inStream.Close()
|
||||
Set-Content -Path $tempSqlFile -Value $content -Encoding UTF8 -NoNewline
|
||||
}
|
||||
|
||||
# 执行恢复
|
||||
$mysqlArgs = @("-h", $db.Host, "-P", $db.Port, "-u", $db.User, "-p$($db.Pass)", $TestDb)
|
||||
Get-Content $tempSqlFile -Raw | & mysql @mysqlArgs 2>$null
|
||||
|
||||
Remove-Item $tempSqlFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$restoreEnd = Get-Date
|
||||
$restoreDuration = ($restoreEnd - $restoreStart).TotalSeconds
|
||||
Write-Host " PASS: Restore completed in $([int]$restoreDuration)s" -ForegroundColor Green
|
||||
Step-Result "3 - 从备份恢复" "PASSED" "恢复完成,耗时 $([int]$restoreDuration) 秒"
|
||||
}
|
||||
catch {
|
||||
Write-Host " FAIL: Restore failed: $_" -ForegroundColor Red
|
||||
Step-Result "3 - 从备份恢复" "FAILED" "从备份恢复失败: $_"
|
||||
$overallStatus = "FAILED"
|
||||
if (-not $NoCleanup) {
|
||||
try { Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent } catch {}
|
||||
}
|
||||
Append-Report "## 演练结果: ❌ FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 步骤 4: 数据完整性检查
|
||||
Write-Host "[4/6] Running data integrity checks..."
|
||||
$testTables = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TestDb';" -Silent -Scalar
|
||||
$sourceTables = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.DbName)';" -Silent -Scalar
|
||||
|
||||
Write-Host " Test DB tables: $testTables"
|
||||
Write-Host " Source DB tables: $sourceTables"
|
||||
|
||||
$testRecords = Invoke-MySql -Query "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$TestDb';" -Silent -Scalar
|
||||
$sourceRecords = Invoke-MySql -Query "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$($db.DbName)';" -Silent -Scalar
|
||||
|
||||
Write-Host " Test DB records: $testRecords"
|
||||
Write-Host " Source DB records: $sourceRecords"
|
||||
|
||||
$integrityDetail = @"
|
||||
| 指标 | 测试库 | 源库 |
|
||||
|------|--------|------|
|
||||
| 表数量 | $testTables | $sourceTables |
|
||||
| 记录数(近似) | $testRecords | $sourceRecords |
|
||||
"@
|
||||
|
||||
if ([int]$testTables -ge [int]$sourceTables) {
|
||||
Write-Host " PASS: Table count matches" -ForegroundColor Green
|
||||
Step-Result "4 - 数据完整性检查" "PASSED" $integrityDetail
|
||||
}
|
||||
else {
|
||||
Write-Host " WARN: Test DB has fewer tables than source" -ForegroundColor Yellow
|
||||
Step-Result "4 - 数据完整性检查" "WARN" "$integrityDetail`n`n⚠️ 测试库表数量少于源库"
|
||||
}
|
||||
|
||||
# 步骤 5: 冒烟测试
|
||||
Write-Host "[5/6] Running smoke tests..."
|
||||
$smokePassed = 0
|
||||
$smokeFailed = 0
|
||||
$smokeDetail = ""
|
||||
|
||||
# 测试 1: 检查 users 表
|
||||
try {
|
||||
$userCount = Invoke-MySql -Query "SELECT COUNT(*) FROM users;" -Database $TestDb -Silent -Scalar
|
||||
$smokePassed++
|
||||
$smokeDetail += "- ✅ users 表查询成功: $userCount 条记录`n"
|
||||
Write-Host " PASS: users table query: $userCount records" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
$smokeDetail += "- ⚠️ users 表不存在或查询失败`n"
|
||||
Write-Host " WARN: users table not found or query failed" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 测试 2: 检查 schools 表
|
||||
try {
|
||||
$schoolCount = Invoke-MySql -Query "SELECT COUNT(*) FROM schools;" -Database $TestDb -Silent -Scalar
|
||||
$smokePassed++
|
||||
$smokeDetail += "- ✅ schools 表查询成功: $schoolCount 条记录`n"
|
||||
Write-Host " PASS: schools table query: $schoolCount records" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
$smokeDetail += "- ⚠️ schools 表不存在或查询失败`n"
|
||||
Write-Host " WARN: schools table not found or query failed" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 测试 3: 基础表查询
|
||||
try {
|
||||
$baseTableCount = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TestDb' AND table_type='BASE TABLE';" -Silent -Scalar
|
||||
if ([int]$baseTableCount -gt 0) {
|
||||
$smokePassed++
|
||||
$smokeDetail += "- ✅ 基础表查询成功: $baseTableCount 个基础表`n"
|
||||
Write-Host " PASS: Base table query: $baseTableCount tables" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$smokeFailed++
|
||||
$smokeDetail += "- ❌ 基础表查询失败`n"
|
||||
Write-Host " FAIL: Base table query failed" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$smokeFailed++
|
||||
$smokeDetail += "- ❌ 基础表查询失败`n"
|
||||
Write-Host " FAIL: Base table query failed" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Step-Result "5 - 冒烟测试" "PASSED" "通过: $smokePassed, 失败: $smokeFailed`n`n$smokeDetail"
|
||||
|
||||
# 步骤 6: 清理测试数据库
|
||||
Write-Host "[6/6] Cleaning up test database..."
|
||||
if ($NoCleanup) {
|
||||
Write-Host " SKIP: Cleanup skipped (--NoCleanup)" -ForegroundColor Yellow
|
||||
Step-Result "6 - 清理测试数据库" "SKIPPED" "演练后保留测试数据库 ``$TestDb``"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent
|
||||
Write-Host " PASS: Test database dropped: $TestDb" -ForegroundColor Green
|
||||
Step-Result "6 - 清理测试数据库" "PASSED" "测试数据库 ``$TestDb`` 已删除"
|
||||
}
|
||||
catch {
|
||||
Write-Host " WARN: Could not drop test database (manual cleanup required)" -ForegroundColor Yellow
|
||||
Step-Result "6 - 清理测试数据库" "WARN" "⚠️ 无法删除测试数据库 ``$TestDb``,需手动清理"
|
||||
}
|
||||
}
|
||||
|
||||
# 生成总结
|
||||
$drillEnd = Get-Date
|
||||
$drillDuration = ($drillEnd - $drillStart).TotalSeconds
|
||||
|
||||
Append-Report "## 演练结果"
|
||||
Append-Report ""
|
||||
if ($overallStatus -eq "SUCCESS") {
|
||||
Append-Report "**状态**: ✅ 成功"
|
||||
}
|
||||
else {
|
||||
Append-Report "**状态**: ❌ 失败"
|
||||
}
|
||||
Append-Report "**总耗时**: $([int]$drillDuration) 秒"
|
||||
Append-Report "**备份文件**: ``$BackupFile``"
|
||||
Append-Report "**测试数据库**: ``$TestDb``"
|
||||
Append-Report ""
|
||||
Append-Report "## RTO/RPO 评估"
|
||||
Append-Report ""
|
||||
Append-Report "- **RTO 目标**: 4 小时"
|
||||
Append-Report "- **本次恢复耗时**: $([int]$restoreDuration) 秒 ($([int]($restoreDuration / 60)) 分钟)"
|
||||
if ($restoreDuration -lt 14400) {
|
||||
Append-Report "- **RTO 评估**: ✅ 达标"
|
||||
}
|
||||
else {
|
||||
Append-Report "- **RTO 评估**: ⚠️ 需关注"
|
||||
}
|
||||
Append-Report "- **RPO 目标**: 24 小时(取决于备份频率)"
|
||||
Append-Report ""
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Drill Summary ===" -ForegroundColor Cyan
|
||||
Write-Host "Status: $overallStatus"
|
||||
Write-Host "Duration: $([int]$drillDuration)s"
|
||||
Write-Host "Report: $ReportFile"
|
||||
Write-Host ""
|
||||
|
||||
if ($overallStatus -eq "SUCCESS") {
|
||||
exit 0
|
||||
}
|
||||
else {
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user