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

View File

@@ -214,6 +214,30 @@
- 功能:默认存储 Provider 单例,替换此实例可迁移到 OSS/S3
- 被以下模块使用:`app/api/files/batch-delete/route.ts`
#### `useA11yId`
- 签名:`useA11yId(prefix: string): string`
- 功能:基于 `React.useId` 生成 SSR 安全的唯一 ID用于 `aria-describedby``aria-labelledby`
- 依赖:`react`
- 被以下模块使用待扩展表单组件、a11y 组件)
#### `mergeA11yProps`
- 签名:`mergeA11yProps<T extends Record<string, unknown>>(...props: (T | undefined | null | false)[]): T`
- 功能:合并多组 aria/data 属性,普通属性后者覆盖前者,`aria-*`/`data-*` 字符串属性以空格拼接
- 依赖:无
- 被以下模块使用:待扩展
#### `describeInput`
- 签名:`describeInput(describedBy?: string, error?: string, hint?: string): { ariaDescribedBy?: string; ariaInvalid?: boolean }`
- 功能:计算输入框的 `aria-describedby`(合并多个 ID`aria-invalid`error 存在则为 true
- 依赖:无
- 被以下模块使用:待扩展(表单组件)
#### `loadingAria`
- 签名:`loadingAria(isLoading: boolean): { ariaBusy: boolean; ariaLive: "polite" | "assertive" }`
- 功能:提供加载状态的 `aria-busy``aria-live=polite` 属性
- 依赖:无
- 被以下模块使用:待扩展
### 导出常量与实例
#### `Permissions` (常量对象)
@@ -300,7 +324,31 @@
- 基于:`@radix-ui/react-switch`
- Props: Radix Switch Root props`checked`, `onCheckedChange`, `disabled`, `id`, `aria-label` 等)
- 功能:开关切换 UI 组件shadcn 风格checked/unchecked 两态)
- 被使用:`settings/components/notification-preferences-form.tsx`
- 被使用settings/components/notification-preferences-form.tsx
#### `SkipLink`
- 文件:`components/a11y/skip-link.tsx`
- Props: `{ href?, children?, ...AnchorHTMLAttributes }`,默认 href=`#main-content`,默认文字"跳转到主内容"
- 功能:跳转链接组件,视觉隐藏,获得焦点时高对比度显示,供键盘用户跳过导航直达主内容
- 被使用:待替换 `app/(dashboard)/layout.tsx` 中的内联 skip-link
#### `VisuallyHidden`
- 文件:`components/a11y/visually-hidden.tsx`
- Props: `{ children?, ...HTMLAttributes }`
- 功能:视觉隐藏但屏幕阅读器可读,用于图标按钮文字描述、表单辅助说明
- 被使用:待扩展
#### `FocusTrap`
- 文件:`components/a11y/focus-trap.tsx`
- Props: `{ children, active?, initialFocusRef?, restoreFocus?, className? }`
- 功能:焦点陷阱组件,捕获 Tab/Shift+Tab 在容器内循环,支持初始焦点与焦点恢复,用于模态框/对话框
- 被使用待扩展Dialog/Sheet 自定义场景)
#### `AriaStatus`
- 文件:`components/a11y/aria-status.tsx`
- Props: `{ children?, politeness?, atomic?, ...HTMLAttributes }`,默认 politeness=`polite`atomic=`true`
- 功能ARIA 状态通知区域,渲染 `aria-live` 区域role=status用于页面级状态通知如"加载中"、"已保存"
- 被使用:待扩展
### 导出 Hooks
@@ -2411,6 +2459,112 @@
---
## 模块notifications
### 模块职责
通知渠道集成层:基于用户通知偏好(`notification_preferences` 表)将通知分发到站内消息 / SMS / 微信公众号 / 邮件多渠道。所有渠道实现统一 `NotificationChannelSender` 接口dispatcher 按偏好并行发送。支持 Mock 模式(开发环境无需外部服务即可运行)。
### 模块路径
`src/modules/notifications`
### 依赖关系
- 依赖 `shared`db, auth-guard, types
- 依赖 `messaging`(复用 `notification-preferences.getNotificationPreferences``data-access.createNotification`
- 所有渠道文件首行 `import "server-only"`,外部 SDK 使用动态 import
### 导出函数 (actions.ts)
> 使用 `requirePermission(MESSAGE_SEND)` 校验权限(项目无独立 NOTIFICATION_SEND 权限点,复用 MESSAGE_SEND
| 函数 | 权限 | 核心功能 |
|------|------|---------|
| `sendNotificationAction` | MESSAGE_SEND | 发送通知给指定用户(按偏好多渠道分发) |
| `sendClassNotificationAction` | MESSAGE_SEND | 发送班级通知(批量发送给班级所有学生;教师只能给自己所教班级发送,通过 dataScope 校验) |
### 导出函数 (dispatcher.ts)
> 文件标记 `"server-only"`。
#### `sendNotification`
- 签名:`sendNotification(payload: NotificationPayload): Promise<ChannelSendResult[]>`
- 功能:读取用户通知偏好 + 联系方式按偏好选择渠道in_app 总是启用sms 需 smsEnabled+phoneemail 需 emailEnabled+emailwechat 需 pushEnabled+openId并行发送记录日志
- 依赖:`data-access.getUserNotificationPreferences`, `data-access.getUserContactInfo`, `data-access.logNotificationSendBatch`, 各渠道 `createXxxSender`
- 被使用:`sendNotificationAction`, `sendClassNotificationAction`
#### `sendBatchNotifications`
- 签名:`sendBatchNotifications(payloads: NotificationPayload[]): Promise<ChannelSendResult[][]>`
- 功能:批量发送通知(每个用户独立选择渠道,并行发送)
- 依赖:`sendNotification`
- 被使用:`sendClassNotificationAction`
### 导出函数 (data-access.ts)
> 文件标记 `"server-only"`。
| 函数 | 签名 | 核心功能 |
|------|------|---------|
| `getUserNotificationPreferences` | `(userId: string) => Promise<NotificationPreferences>` | 获取用户通知偏好(复用 messaging.notification-preferences |
| `getUserContactInfo` | `(userId: string) => Promise<ChannelRecipient>` | 获取用户联系方式phone/emailwechatOpenId 暂不支持users 表无此字段React cache 包装) |
| `logNotificationSend` | `(result: ChannelSendResult) => void` | 记录单条发送日志(当前 console.info未来可扩展 notification_logs 表) |
| `logNotificationSendBatch` | `(results: ChannelSendResult[]) => void` | 批量记录发送日志 |
### 渠道实现 (channels/)
> 所有渠道文件首行 `import "server-only"`,外部 SDK 使用动态 import 避免增加构建体积。
| 文件 | 渠道 | 工厂函数 | 说明 |
|------|------|---------|------|
| `sms-channel.ts` | sms | `createSmsSender()` | 支持 aliyun/tencent/mock根据 `SMS_PROVIDER` 环境变量选择);模板变量替换 title/content |
| `wechat-channel.ts` | wechat | `createWechatSender()` | 微信公众号模板消息access_token 带缓存(提前 5 分钟刷新);配置完整用真实发送器,否则 Mock |
| `email-channel.ts` | email | `createEmailSender()` | Nodemailer SMTPHTML 模板按 type 着色;配置 EMAIL_HOST 启用,否则 Mock |
| `in-app-channel.ts` | in_app | `createInAppSender()` | 复用 messaging.data-access.createNotification 写入 message_notifications 表;总是启用 |
| `types.ts` | - | - | 渠道接口定义NotificationChannelSender, ChannelRecipient |
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `SMS_PROVIDER` | `mock` | SMS 渠道 provideraliyun/tencent/mock |
| `SMS_ACCESS_KEY_ID` | - | SMS AccessKey ID |
| `SMS_ACCESS_KEY_SECRET` | - | SMS AccessKey Secret |
| `SMS_SIGN_NAME` | - | SMS 签名 |
| `SMS_TEMPLATE_CODE` | - | SMS 模板 ID |
| `WECHAT_APP_ID` | - | 微信公众号 AppID |
| `WECHAT_APP_SECRET` | - | 微信公众号 AppSecret |
| `WECHAT_TEMPLATE_ID` | - | 微信模板消息 ID |
| `EMAIL_HOST` | - | SMTP 主机(配置后启用真实发送) |
| `EMAIL_PORT` | `587` | SMTP 端口 |
| `EMAIL_USER` | - | SMTP 用户名 |
| `EMAIL_PASS` | - | SMTP 密码 |
| `EMAIL_FROM` | `noreply@example.com` | 发件人地址 |
### 类型/接口
#### `NotificationChannel`
- 定义:`"in_app" | "email" | "sms" | "wechat"`
- 被使用:所有渠道文件, dispatcher
#### `NotificationPayload`
- 定义:`{ userId, title, content, type: "info"|"warning"|"error"|"success", metadata?, actionUrl? }`
- 被使用dispatcher, actions, 所有渠道
#### `ChannelSendResult`
- 定义:`{ channel, success, messageId?, error?, sentAt }`
- 被使用dispatcher, actions, 所有渠道
#### `NotificationChannelSender`(接口)
- 定义:`{ channel: NotificationChannel, send(payload, recipient): Promise<ChannelSendResult>, sendBatch(items): Promise<ChannelSendResult[]> }`
- 被使用:所有渠道实现, dispatcher
#### `ChannelRecipient`(接口)
- 定义:`{ userId, phone?, email?, wechatOpenId? }`
- 被使用:所有渠道, data-access.getUserContactInfo
### 文档
- `docs/notifications/channels.md`通知渠道配置说明、Mock 模式、生产环境配置、扩展新渠道指南
---
## 模块attendance
### 模块职责
@@ -2871,6 +3025,7 @@
| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - | - |
| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **notifications** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-preferences,data-access.createNotification | - | - | - | - | - |
| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **proctoring** | db,auth-guard(requirePermission,requireAuth),types,components.ui,hooks.usePermission | auth | schema.exams,examSubmissions,examProctoringEvents | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
@@ -3143,8 +3298,37 @@
| Job | 触发条件 | 说明 |
|-----|---------|------|
| `build-deploy` | push/PR to main | 构建、测试、部署到 Docker自托管 runner CDCD |
| `security-audit` | push/PR to main | 依赖安全审计:`npm audit` moderate/critical 检查,上传 audit-report.json artifact |
| `scheduled-backup` | schedule cron `0 2 * * *` | 每天凌晨 2 点执行数据库备份上传 backups/ artifact保留 30 天) |
| `security-scan` | push/PR to mainneeds: build-deploy | 完整安全扫描npm audit + Snyk + Trivy FS + OWASP ZAP 基线扫描,所有步骤 continue-on-error上传 security-reports artifactaudit-report.json/trivy-fs-report.json/snyk.sarif |
| `scheduled-backup` | schedule cron `0 2 * * *` | 每天凌晨 2 点执行数据库备份→校验完整性→异地同步,上传 backups/ artifact保留 30 天) |
| `backup-verify` | scheduleneeds: scheduled-backup | 下载备份 artifact,独立校验备份完整性,运行健康检查,上传 backup-verify-report artifact保留 7 天) |
| `weekly-dr-drill` | scheduleneeds: backup-verify,每周触发) | 灾备演练:从备份恢复到测试数据库,验证数据完整性,上传 dr-drill-report artifact保留 90 天) |
### 灾备演练工作流 (`.gitea/workflows/dr-drill.yml`)
独立灾备演练工作流,触发方式:定时 `cron 0 4 * * 1`(每周一凌晨 4 点)/ 手动 `workflow_dispatch`(可指定 backup_file、no_cleanup
| 步骤 | 说明 |
|------|------|
| 安装 MySQL 客户端 | apt-get install mysql-client |
| 准备备份 | 下载 db-backup artifact 或现场执行 backup-db.sh |
| 执行演练 | 运行 scripts/dr-drill.sh创建测试库→恢复→完整性检查→冒烟测试→清理→报告 |
| 上传报告 | dr-drill-report artifact保留 90 天) |
| 失败通知 | webhook 通知运维团队DR_NOTIFICATION_WEBHOOK |
### 安全扫描工作流 (`.gitea/workflows/security.yml`)
独立深度安全扫描工作流,触发方式:定时 `cron 0 3 * * 1`(每周一凌晨 3 点)/ 手动 `workflow_dispatch`(可指定 target_url、skip_dast
| 步骤 | 工具 | 类型 | 输出 |
|------|------|------|------|
| 依赖扫描 | npm audit | 依赖 | audit-report.json |
| 深度依赖 + 静态分析 | Snykseverity-threshold=medium | 依赖 + 代码 | snyk.sarif |
| 文件系统扫描 | Trivy fs | 代码 + 依赖 | trivy-fs-report.json |
| 容器镜像扫描 | Trivy image构建 nextjs-app:scan 镜像) | 容器 | trivy-image-report.json |
| DAST | OWASP ZAP baseline | 动态 | 控制台报告 |
| 汇总报告 | shell + jq | 汇总 | security-summary.md |
所有报告上传为 artifact `security-reports-full`。安全扫描配置文件:`.gitea/suppressions.json`Snyk 漏洞抑制)、`.trivyignore`Trivy CVE 忽略列表)。
### 运维脚本 (`scripts/`)
@@ -3152,9 +3336,17 @@
|------|------|
| `scripts/audit.sh` | Bash 依赖审计脚本,运行 `npm audit --audit-level=moderate`,失败时生成 audit-report.json |
| `scripts/audit.ps1` | PowerShell 版本依赖审计脚本Windows 环境) |
| `scripts/security-scan.sh` | Bash 本地安全扫描脚本npm audit + Trivy fs彩色报告退出码 0=无高危/1=有高危 |
| `scripts/security-scan.ps1` | PowerShell 版本本地安全扫描脚本Windows 环境) |
| `scripts/backup-db.sh` | MySQL 数据库备份脚本,从 DATABASE_URL 解析连接信息gzip 压缩备份,保留 30 天 |
| `scripts/restore-db.sh` | MySQL 数据库恢复脚本,从指定备份文件恢复 |
| `scripts/test-backup.sh` | 备份流程测试脚本,执行一次备份并验证 |
| `scripts/backup-verify.sh` | 备份完整性校验脚本:检查文件存在/大小/gzip 完整性/SQL 内容结构/SQL 语法(可选,需 DATABASE_URL,退出码 0=通过/1=失败 |
| `scripts/backup-offsite-sync.sh` | 异地备份同步脚本:支持 S3/OSS/NFS 后端,同步后校验文件数量,清理远程过期备份(保留 90 天),使用 aws-cli/rclone/ossutil/rsync |
| `scripts/dr-drill.sh` | 灾备演练脚本Bash创建测试库→从备份恢复→数据完整性检查→冒烟测试→清理→生成报告到 docs/dr/reports/,退出码 0=成功/1=失败 |
| `scripts/dr-drill.ps1` | 灾备演练脚本Windows PowerShell 5.1+):功能同 Bash 版本 |
| `scripts/failover.sh` | 故障切换脚本:检测主库健康→提升备库→更新应用配置→重启应用→验证切换,支持手动/半自动/演练模式 |
| `scripts/health-check.sh` | 健康检查脚本:检查应用 HTTP/数据库连接/磁盘空间/备份新鲜度,输出 JSON 报告,退出码 0=健康/1=异常 |
### package.json 脚本
@@ -3162,8 +3354,41 @@
|------|------|------|
| `audit` | `npm audit --audit-level=moderate` | 依赖安全审计 |
| `audit:report` | `npm audit --json > audit-report.json` | 生成 JSON 审计报告 |
| `security:audit` | `npm audit --audit-level=moderate` | 依赖安全审计security 别名) |
| `security:scan` | `bash scripts/security-scan.sh` | 本地完整安全扫描npm audit + Trivy fs |
| `backup` | `bash scripts/backup-db.sh` | 执行数据库备份 |
| `restore` | `bash scripts/restore-db.sh` | 执行数据库恢复 |
| `dr:backup-verify` | `bash scripts/backup-verify.sh` | 校验备份完整性 |
| `dr:offsite-sync` | `bash scripts/backup-offsite-sync.sh` | 异地备份同步 |
| `dr:drill` | `bash scripts/dr-drill.sh` | 灾备演练Bash |
| `dr:drill:ps1` | `powershell -ExecutionPolicy Bypass -File scripts/dr-drill.ps1` | 灾备演练PowerShell |
| `dr:health-check` | `bash scripts/health-check.sh` | 健康检查JSON 报告) |
| `dr:failover` | `bash scripts/failover.sh` | 故障切换 |
### 灾备文档 (`docs/dr/`)
| 文档 | 用途 |
|------|------|
| `docs/dr/dr-plan.md` | 灾备计划文档RTO/RPO 定义4h/24h、备份策略、故障切换流程、联系人列表、恢复步骤 |
| `docs/dr/dr-runbook.md` | 灾备操作手册:数据库故障/应用故障/备份失败/异地同步失败/演练失败/磁盘不足场景的诊断与处理 |
| `docs/dr/reports/` | 灾备演练报告存档目录Markdown 格式,由 dr-drill.sh 生成) |
| `docs/dr/logs/` | 故障切换日志目录(由 failover.sh 生成) |
### 灾备环境变量 (`.env.example`)
| 变量 | 用途 |
|------|------|
| `BACKUP_OFFSITE_BACKEND` | 异地备份后端类型: s3/oss/nfs/none |
| `BACKUP_OFFSITE_REMOTE` | 远程存储路径 |
| `BACKUP_OFFSITE_BUCKET` | 存储桶名称(仅 s3/oss |
| `BACKUP_OFFSITE_ACCESS_KEY` | 访问密钥 |
| `BACKUP_OFFSITE_SECRET_KEY` | 秘密密钥 |
| `BACKUP_OFFSITE_REGION` | 区域(默认 us-east-1 |
| `BACKUP_OFFSITE_RETENTION_DAYS` | 远程备份保留天数(默认 90 |
| `DR_DRILL_TEST_DB` | 演练测试数据库名(默认 next_edu_dr_drill |
| `HEALTH_CHECK_URL` | 应用健康检查 URL默认 http://localhost:8015 |
| `DATABASE_URL_STANDBY` | 备库连接 URL故障切换时使用 |
| `FAILOVER_APP_NAME` | 应用容器名(默认 nextjs-app |
---
@@ -3181,10 +3406,40 @@
### Playwright 配置 (`playwright.config.ts`)
- `testDir`: `./tests/e2e`
- `testDir`: `./tests`(顶层,由各 project 通过 `testDir` 限定范围)
- `baseURL`: `http://127.0.0.1:3000`
- `webServer`: 自动启动 `npm run dev`,端口 3000超时 180s
- `webServer.env`: 注入 `SKIP_ENV_VALIDATION=1``NEXTAUTH_SECRET``NEXTAUTH_URL``DATABASE_URL`(测试库)
- `projects`: chromiumCI 通道为 undefined本地为 chrome
- `projects`:
- `chromium`E2E 测试,`testDir: ./tests/e2e`CI 通道为 undefined本地为 chrome
- `visual-chromium`(视觉回归测试,`testDir: ./tests/visual`CI 通道为 undefined本地为 chrome
- `snapshotPathTemplate`: `{testDir}/__screenshots__/{testFilePath}/{arg}{ext}`
- `expect.toHaveScreenshot`: `maxDiffPixelRatio: 0.01``animations: "disabled"``caret: "hide"`
- `retries`: CI 2 次,本地 0 次
- `workers`: CI 2 个,本地默认
---
## 视觉回归测试 (`tests/visual/`)
| 测试文件 | 覆盖范围 | 依赖 |
|---------|---------|------|
| `homepage.spec.ts` | 登录页在 desktop/tablet/mobile × light/dark 下的快照 | 无需 DB |
| `admin-dashboard.spec.ts` | 管理员仪表盘整页 + 侧边栏/主内容区组件快照 | DATABASE_URL + admin 账号 |
| `teacher-dashboard.spec.ts` | 教师仪表盘整页 + 侧边栏/主内容区组件快照 | DATABASE_URL + teacher 账号 |
| `student-dashboard.spec.ts` | 学生仪表盘整页 + 侧边栏/主内容区组件快照 | DATABASE_URL + student 账号 |
### 视觉测试辅助 (`tests/visual/helpers/`)
| 文件 | 用途 |
|------|------|
| `auth.ts` | 登录辅助 `setupAuthState(role)``loginByUI(page, role)`,测试账号默认 admin@xiaoxue.edu.cn / 123456 |
| `visual-helpers.ts` | `setViewport``setTheme``waitForPageReady``maskDynamicElements``buildMaskOption` |
### 视觉测试配置 (`tests/visual/visual.config.ts`)
- 视口: desktop 1920×1080、tablet 768×1024、mobile 375×812
- 主题: light、dark
- 快照目录: `tests/visual/__screenshots__/`
- storageState 目录: `tests/visual/.auth/`(已加入 .gitignore
- 默认容差: `maxDiffPixelRatio: 0.01`