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

@@ -0,0 +1,276 @@
# 无障碍审计报告 (A11y Audit)
> 审计日期2026-06-17
> 审计范围:`src/shared/` 核心组件与新增无障碍工具
> 合规目标WCAG 2.1 AA
---
## 一、已审计组件与 ARIA 改进
### 1. 新增无障碍工具库
| 文件 | 导出 | 用途 |
|------|------|------|
| `src/shared/lib/a11y.ts` | `useA11yId` | 基于 `React.useId` 生成 SSR 安全的唯一 ID用于 `aria-describedby``aria-labelledby` |
| `src/shared/lib/a11y.ts` | `mergeA11yProps` | 合并多组 aria/data 属性,`aria-*`/`data-*` 字符串属性以空格拼接 |
| `src/shared/lib/a11y.ts` | `describeInput` | 计算输入框的 `aria-describedby``aria-invalid` |
| `src/shared/lib/a11y.ts` | `loadingAria` | 提供加载状态的 `aria-busy``aria-live` 属性 |
### 2. 新增 Hook
| 文件 | 导出 | 用途 |
|------|------|------|
| `src/shared/hooks/use-aria-live.ts` | `useAriaLive` | 管理 aria-live 区域,支持 polite/assertive 通知,自动清除过期通知(默认 5s返回 `{ announce, liveRegion }` |
### 3. 新增 a11y 组件
| 文件 | 组件 | 用途 |
|------|------|------|
| `src/shared/components/a11y/skip-link.tsx` | `SkipLink` | 跳转链接,视觉隐藏,获得焦点时高对比度显示,默认跳转 `#main-content` |
| `src/shared/components/a11y/visually-hidden.tsx` | `VisuallyHidden` | 视觉隐藏但屏幕阅读器可读,用于图标按钮文字描述、表单辅助说明 |
| `src/shared/components/a11y/focus-trap.tsx` | `FocusTrap` | 焦点陷阱,捕获 Tab/Shift+Tab 循环,支持初始焦点与焦点恢复 |
| `src/shared/components/a11y/aria-status.tsx` | `AriaStatus` | ARIA 状态通知区域,渲染 `aria-live` 区域,支持 polite/assertive |
### 4. 增强的核心 UI 组件
#### `src/shared/components/ui/table.tsx`
| 组件 | ARIA 改进 |
|------|-----------|
| `Table` | 默认 `role="table"`(可覆盖),支持 `aria-rowcount``aria-colcount` |
| `TableHeader` | 默认 `role="rowgroup"` |
| `TableBody` | 默认 `role="rowgroup"` |
| `TableFooter` | 默认 `role="rowgroup"` |
| `TableRow` | 默认 `role="row"` |
| `TableHead` | 默认 `role="columnheader"`,支持 `scope` 属性(`col`/`row`/`colgroup`/`rowgroup` |
| `TableCell` | 默认 `role="cell"` |
| `TableCaption` | 已有 `<caption>` 元素,为表格提供可访问标题 |
所有 `role` 均为默认值,可通过 props 覆盖,**完全向后兼容**。
#### `src/shared/components/ui/dialog.tsx`
| 改进项 | 说明 |
|--------|------|
| `aria-modal="true"` | 显式添加到 `DialogContent`Radix 已内置,此处显式标注便于审计) |
| 关闭按钮 `aria-label="关闭"` | 添加明确的中文无障碍标签 |
| 关闭按钮 sr-only 文本 | 由 "Close" 改为 "关闭",与项目语言一致 |
| 焦点管理 | Radix Dialog 原语已内置:打开时焦点移入内容区,关闭时恢复到触发元素 |
| Esc 键关闭 | Radix Dialog 原语已内置 |
| `aria-labelledby` | Radix 自动关联 `DialogTitle` 的 id 到 `aria-labelledby` |
---
## 二、待改进项
| 优先级 | 项目 | 说明 |
|--------|------|------|
| 高 | 表单组件 `aria-describedby` 关联 | `Input``Textarea``Select` 等需配合 `describeInput` 工具函数,将错误提示和帮助文本的 id 关联到输入框 |
| 高 | 图标按钮 `aria-label` | 全项目排查仅含图标无文字的按钮,补充 `aria-label` 或使用 `VisuallyHidden` |
| 中 | `Sheet`/`AlertDialog` 焦点管理 | 参照 `Dialog` 增强,显式添加 `aria-modal` 和中文关闭标签 |
| 中 | 数据表格 `aria-rowcount`/`aria-colcount` | 在使用 `@tanstack/react-table` 的页面中,为 `Table` 传入总行数和列数 |
| 中 | 面包屑 `aria-label="面包屑导航"` | `Breadcrumb` 容器添加 `nav``aria-label` |
| 中 | 分页组件 `aria-label` | 分页导航添加 `aria-label="分页"`,当前页使用 `aria-current="page"` |
| 低 | 动态内容变更播报 | 在表单提交、数据加载场景接入 `useAriaLive` 进行状态播报 |
| 低 | 颜色对比度审查 | 使用 axe DevTools 全量扫描颜色对比度是否达到 4.5:1正文/ 3:1大文字 |
| 低 | 跳转链接全局应用 | 将 `app/(dashboard)/layout.tsx` 中的内联 skip-link 替换为 `SkipLink` 组件 |
---
## 三、屏幕阅读器测试指南
### NVDAWindows免费
1. **安装**:从 [nvaccess.org](https://www.nvaccess.org/) 下载安装
2. **启动/退出**`Ctrl + Alt + N` 启动,`Insert + Q` 退出
3. **核心快捷键**
- `↓` / `↑`:逐行阅读
- `Tab` / `Shift + Tab`:在可聚焦元素间移动
- `H`:按标题跳转
- `T`:跳转到表格
- `F`:跳转到表单控件
- `B`:跳转到按钮
- `Insert + Tab`:播报当前焦点元素
- `Insert + Space`:切换浏览/焦点模式
4. **测试要点**
- 打开页面后 Tab 到 SkipLink确认可跳转到主内容区
- Tab 遍历所有交互元素,确认每个元素有可读的名称
- 打开 Dialog确认焦点移入对话框、Esc 可关闭、关闭后焦点回到触发按钮
- 在表格中按 `T` 跳转,确认表格标题和行列关系正确播报
### VoiceOvermacOS内置
1. **启动/退出**`Cmd + F5`
2. **核心快捷键**
- `Ctrl + Option + →` / `←`:逐元素导航
- `Ctrl + Option + Cmd + H`:按标题跳转
- `Ctrl + Option + Cmd + T`:跳转到表格
- `Ctrl + Option + Space`:激活当前元素
- `Ctrl + Option + U`打开转子Rotor按元素类型浏览
3. **测试要点**
- 确认 SkipLink 获得焦点时高对比度显示
- 确认 `aria-live` 区域在表单提交后播报结果
- 确认 `VisuallyHidden` 内容被播报但不可见
- 确认 Dialog 打开时 VoiceOver 朗读对话框标题
### 通用测试清单
- [ ] 所有交互元素可通过键盘访问Tab/Shift+Tab/Enter/Space/Esc
- [ ] 焦点顺序符合视觉阅读顺序
- [ ] 焦点可见focus 样式清晰)
- [ ] 每个交互元素有可访问名称(`aria-label` 或可见文字)
- [ ] 表单错误信息通过 `aria-live``aria-describedby` 播报
- [ ] 加载状态通过 `aria-busy``aria-live` 播报
- [ ] 模态框打开时焦点被困在框内,关闭后恢复
---
## 四、WCAG 2.1 AA 合规检查清单
### 原则一:可感知 (Perceivable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 1.1.1 非文本内容 | ✅ | 图标按钮通过 `aria-label``VisuallyHidden` 提供文字替代 |
| 1.2.1 纯音频/视频 | ⚠️ | 项目暂无音视频内容,后续如需添加需提供字幕/文字稿 |
| 1.3.1 信息与关系 | ✅ | 表格通过 `role``scope` 表达行列关系;表单通过 `aria-describedby` 关联说明 |
| 1.3.2 有意义的顺序 | ✅ | DOM 顺序与视觉顺序一致 |
| 1.3.3 感官特征 | ✅ | 不仅依赖颜色/位置传达信息,配合文字说明 |
| 1.3.4 方向 | ✅ | 不限制屏幕方向 |
| 1.4.1 颜色的使用 | ✅ | 错误状态除颜色外配合文字/图标 |
| 1.4.3 对比度(最低) | ⚠️ | 需全量审查,语义色 `muted-foreground` 需确认对比度 ≥ 4.5:1 |
| 1.4.4 文字缩放 | ✅ | 使用 `rem`/`em` 单位,支持 200% 缩放 |
| 1.4.10 回流 | ✅ | 响应式布局,支持 320px 宽度 |
| 1.4.11 非文字对比度 | ✅ | 边框、焦点环使用语义色,对比度 ≥ 3:1 |
### 原则二:可操作 (Operable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 2.1.1 键盘 | ✅ | 所有交互可通过键盘操作 |
| 2.1.2 无键盘陷阱 | ✅ | `FocusTrap` 仅在模态框激活时使用Esc 可退出 |
| 2.1.4 字符快捷键 | ✅ | 无单字符快捷键 |
| 2.2.1 计时可调 | ✅ | 无超时限制(会话超时由 NextAuth 管理,可延长) |
| 2.3.1 三次闪烁 | ✅ | 无闪烁内容 |
| 2.4.1 跳过区块 | ✅ | `SkipLink` 组件提供跳转到主内容 |
| 2.4.2 页面标题 | ✅ | Next.js metadata 提供页面标题 |
| 2.4.3 焦点顺序 | ✅ | DOM 顺序符合逻辑 |
| 2.4.4 链接目的 | ✅ | 链接文字描述目的,避免"点击这里" |
| 2.4.6 标题与标签 | ✅ | 表单字段使用 `Label` 组件关联 |
| 2.4.7 焦点可见 | ✅ | 所有交互元素有 `focus:ring` 样式 |
| 2.5.3 标签包含名称 | ✅ | 可见标签文字包含在可访问名称中 |
### 原则三:可理解 (Understandable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 3.1.1 页面语言 | ✅ | `<html lang="zh-CN">` |
| 3.1.2 部分语言 | ✅ | 暂无混语言内容 |
| 3.2.1 聚焦 | ✅ | 聚焦不触发意外上下文变更 |
| 3.2.2 输入 | ✅ | 表单提交需明确按钮触发 |
| 3.2.3 一致导航 | ✅ | 侧边栏导航在页面间一致 |
| 3.2.4 一致标识 | ✅ | 功能相同的组件使用一致标识 |
| 3.3.1 错误识别 | ✅ | 表单错误通过 `aria-invalid``aria-describedby` 播报 |
| 3.3.2 标签或说明 | ✅ | 表单字段使用 `Label` 关联,提供 `placeholder` 补充 |
| 3.3.3 错误建议 | ⚠️ | 部分表单错误仅提示"必填",需补充修正建议 |
| 3.3.4 错误预防 | ✅ | 删除/提交关键操作使用 `AlertDialog` 确认 |
### 原则四:健壮 (Robust)
| 准则 | 状态 | 说明 |
|------|------|------|
| 4.1.1 解析 | ✅ | React 保证有效 HTML |
| 4.1.2 名称、角色、值 | ✅ | ARIA 角色和属性正确设置,状态变化通过 `aria-live` 播报 |
| 4.1.3 状态消息 | ✅ | `useAriaLive``AriaStatus` 提供 `aria-live` 状态播报 |
---
## 五、自动化测试工具推荐
| 工具 | 用途 | 链接 |
|------|------|------|
| axe DevTools | 浏览器插件,扫描页面无障碍问题 | https://www.deque.com/axe/devtools/ |
| Lighthouse | Chrome 内置,生成无障碍评分 | Chrome DevTools → Lighthouse |
| @axe-core/playwright | E2E 测试中集成 axe 检查 | https://github.com/dequelabs/axe-core-npm |
| eslint-plugin-jsx-a11y | ESLint 静态检查 JSX 无障碍问题 | https://github.com/jsx-eslint/eslint-plugin-jsx-a11y |
---
## 六、使用示例
### `useAriaLive` — 表单提交结果播报
```tsx
"use client"
import { useAriaLive } from "@/shared/hooks/use-aria-live"
function MyForm() {
const { announce, liveRegion } = useAriaLive()
const handleSubmit = async () => {
const result = await submitAction()
if (result.success) {
announce("保存成功", { politeness: "polite" })
} else {
announce(`保存失败:${result.message}`, { politeness: "assertive" })
}
}
return (
<>
<form onSubmit={handleSubmit}>{/* ... */}</form>
{liveRegion}
</>
)
}
```
### `describeInput` — 输入框错误关联
```tsx
import { useA11yId, describeInput } from "@/shared/lib/a11y"
function EmailField({ error }: { error?: string }) {
const hintId = useA11yId("email-hint")
const errorId = useA11yId("email-error")
const { ariaDescribedBy, ariaInvalid } = describeInput(
hintId,
error ? errorId : undefined
)
return (
<>
<Input
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
/>
<span id={hintId} className="text-muted-foreground text-sm">
</span>
{error && (
<span id={errorId} className="text-destructive text-sm" role="alert">
{error}
</span>
)}
</>
)
}
```
### `FocusTrap` — 自定义模态框
```tsx
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
function CustomModal({ open, onClose, children }) {
return (
<FocusTrap active={open} restoreFocus>
<div role="dialog" aria-modal="true">
{children}
<button onClick={onClose}></button>
</div>
</FocusTrap>
)
}
```

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`

View File

@@ -283,6 +283,38 @@
"purpose": "生成导入模板 Buffer表头加粗+第二行填写说明+示例行)",
"deps": ["exceljs"],
"usedBy": ["users/import-export.generateUserImportTemplate"]
},
{
"name": "useA11yId",
"file": "lib/a11y.ts",
"signature": "useA11yId(prefix: string): string",
"purpose": "基于React.useId生成SSR安全的唯一ID用于aria-describedby、aria-labelledby等",
"deps": ["react"],
"usedBy": ["待扩展表单组件、a11y组件"]
},
{
"name": "mergeA11yProps",
"file": "lib/a11y.ts",
"signature": "mergeA11yProps<T extends Record<string, unknown>>(...props: (T | undefined | null | false)[]): T",
"purpose": "合并多组aria/data属性普通属性后者覆盖前者aria-*/data-*字符串属性以空格拼接",
"deps": [],
"usedBy": ["待扩展"]
},
{
"name": "describeInput",
"file": "lib/a11y.ts",
"signature": "describeInput(describedBy?: string, error?: string, hint?: string): { ariaDescribedBy?: string; ariaInvalid?: boolean }",
"purpose": "计算输入框的aria-describedby合并多个ID与aria-invaliderror存在则为true",
"deps": [],
"usedBy": ["待扩展(表单组件)"]
},
{
"name": "loadingAria",
"file": "lib/a11y.ts",
"signature": "loadingAria(isLoading: boolean): { ariaBusy: boolean; ariaLive: 'polite' | 'assertive' }",
"purpose": "提供加载状态的aria-busy与aria-live=polite属性",
"deps": [],
"usedBy": ["待扩展"]
}
],
"hooks": [
@@ -1318,6 +1350,40 @@
]
}
},
"notifications": {
"path": "src/modules/notifications",
"description": "通知渠道集成层基于用户通知偏好notification_preferences将通知分发到站内消息/SMS/微信公众号/邮件多渠道。所有渠道实现统一 NotificationChannelSender 接口dispatcher 按偏好并行发送。支持 Mock 模式(开发环境无需外部服务)。",
"exports": {
"actions": [
{ "name": "sendNotificationAction", "permission": "MESSAGE_SEND", "signature": "(payload: NotificationPayload) => Promise<ActionState<ChannelSendResult[]>>", "purpose": "发送通知给指定用户(按偏好多渠道分发)", "deps": ["requirePermission", "dispatcher.sendNotification"], "usedBy": ["待扩展"] },
{ "name": "sendClassNotificationAction", "permission": "MESSAGE_SEND", "signature": "(classId: string, payload: Omit<NotificationPayload, 'userId'>) => Promise<ActionState<ChannelSendResult[][]>>", "purpose": "发送班级通知(批量发送给班级所有学生;教师只能给自己所教班级发送)", "deps": ["requirePermission", "db.schema.classEnrollments", "db.schema.classes", "dispatcher.sendBatchNotifications"], "usedBy": ["待扩展"] }
],
"dispatcher": [
{ "name": "sendNotification", "signature": "(payload: NotificationPayload) => Promise<ChannelSendResult[]>", "file": "dispatcher.ts", "purpose": "发送单条通知:读取用户偏好+联系方式,按偏好选择渠道并行发送,记录日志", "deps": ["data-access.getUserNotificationPreferences", "data-access.getUserContactInfo", "data-access.logNotificationSendBatch", "channels.sms-channel.createSmsSender", "channels.wechat-channel.createWechatSender", "channels.email-channel.createEmailSender", "channels.in-app-channel.createInAppSender"], "usedBy": ["sendNotificationAction", "sendClassNotificationAction"] },
{ "name": "sendBatchNotifications", "signature": "(payloads: NotificationPayload[]) => Promise<ChannelSendResult[][]>", "file": "dispatcher.ts", "purpose": "批量发送通知(每个用户独立选择渠道,并行发送)", "deps": ["sendNotification"], "usedBy": ["sendClassNotificationAction"] }
],
"dataAccess": [
{ "name": "getUserNotificationPreferences", "signature": "(userId: string) => Promise<NotificationPreferences>", "file": "data-access.ts", "purpose": "获取用户通知偏好(复用 messaging.notification-preferences.getNotificationPreferences", "deps": ["messaging.notification-preferences.getNotificationPreferences"], "usedBy": ["dispatcher.sendNotification"] },
{ "name": "getUserContactInfo", "signature": "(userId: string) => Promise<ChannelRecipient>", "file": "data-access.ts", "purpose": "获取用户联系方式phone/emailwechatOpenId 暂不支持users 表无此字段)", "deps": ["shared.db", "shared.db.schema.users", "react.cache"], "usedBy": ["dispatcher.sendNotification"] },
{ "name": "logNotificationSend", "signature": "(result: ChannelSendResult) => void", "file": "data-access.ts", "purpose": "记录单条发送日志(当前使用 console.info未来可扩展 notification_logs 表)", "deps": [], "usedBy": ["logNotificationSendBatch"] },
{ "name": "logNotificationSendBatch", "signature": "(results: ChannelSendResult[]) => void", "file": "data-access.ts", "purpose": "批量记录发送日志", "deps": ["logNotificationSend"], "usedBy": ["dispatcher.sendNotification", "dispatcher.sendBatchNotifications"] }
],
"channels": [
{ "name": "createSmsSender", "file": "channels/sms-channel.ts", "purpose": "创建 SMS 渠道发送器aliyun/tencent/mock根据 SMS_PROVIDER 环境变量选择SDK 动态 import", "deps": ["环境变量: SMS_PROVIDER, SMS_ACCESS_KEY_ID, SMS_ACCESS_KEY_SECRET, SMS_SIGN_NAME, SMS_TEMPLATE_CODE"] },
{ "name": "createWechatSender", "file": "channels/wechat-channel.ts", "purpose": "创建微信渠道发送器(配置完整用真实发送器,否则 Mockaccess_token 带缓存)", "deps": ["环境变量: WECHAT_APP_ID, WECHAT_APP_SECRET, WECHAT_TEMPLATE_ID"] },
{ "name": "createEmailSender", "file": "channels/email-channel.ts", "purpose": "创建邮件渠道发送器(配置 EMAIL_HOST 用 Nodemailer SMTP否则 MockHTML 模板按 type 着色)", "deps": ["环境变量: EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM"] },
{ "name": "createInAppSender", "file": "channels/in-app-channel.ts", "purpose": "创建站内消息渠道发送器(复用 messaging.data-access.createNotification 写入 message_notifications 表;总是启用)", "deps": ["messaging.data-access.createNotification"] }
],
"types": [
{ "name": "NotificationChannel", "type": "type", "file": "types.ts", "definition": "'in_app' | 'email' | 'sms' | 'wechat'", "usedBy": ["所有渠道文件", "dispatcher"] },
{ "name": "NotificationPayload", "type": "interface", "file": "types.ts", "definition": "{ userId, title, content, type: 'info'|'warning'|'error'|'success', metadata?, actionUrl? }", "usedBy": ["dispatcher", "actions", "所有渠道"] },
{ "name": "ChannelSendResult", "type": "interface", "file": "types.ts", "definition": "{ channel, success, messageId?, error?, sentAt }", "usedBy": ["dispatcher", "actions", "所有渠道"] },
{ "name": "NotificationChannelConfig", "type": "interface", "file": "types.ts", "definition": "{ enabled, sms?, wechat?, email? }", "usedBy": ["类型定义"] },
{ "name": "NotificationChannelSender", "type": "interface", "file": "channels/types.ts", "definition": "{ channel: NotificationChannel, send(payload, recipient), sendBatch(items) }", "usedBy": ["所有渠道实现", "dispatcher"] },
{ "name": "ChannelRecipient", "type": "interface", "file": "channels/types.ts", "definition": "{ userId, phone?, email?, wechatOpenId? }", "usedBy": ["所有渠道", "data-access.getUserContactInfo"] }
]
}
},
"attendance": {
"path": "src/modules/attendance",
"description": "学生考勤管理:教师按班级/日期点名(单条/批量)、查询考勤记录、统计出勤率/迟到率,学生/家长查看本人/子女考勤汇总,管理员查看全校考勤记录。支持班级考勤规则配置。",
@@ -1610,6 +1676,7 @@
"grades": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "types.permissions", "types.action-state", "db.schema.gradeRecords", "db.schema.classes", "db.schema.classEnrollments", "db.schema.subjects", "db.schema.users", "lib.excel"], "auth": ["auth"]}},
"parent": {"dependsOn": ["shared", "auth", "homework", "classes", "grades"], "uses": {"shared": ["db", "auth-guard.requireAuth", "db.schema.parentStudentRelations", "db.schema.users", "db.schema.grades", "db.schema.classEnrollments", "db.schema.classes", "types"], "auth": ["auth"], "homework": ["data-access.getStudentHomeworkAssignments", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getStudentClasses", "data-access.getStudentSchedule"], "grades": ["data-access.getStudentGradeSummary"]}},
"messaging": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.messages", "db.schema.messageNotifications", "db.schema.notificationPreferences", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "db.schema.grades", "types.permissions", "types.action-state"], "auth": ["auth"]}},
"notifications": {"dependsOn": ["shared", "auth", "messaging"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "types.permissions", "types.action-state"], "auth": ["auth"], "messaging": ["notification-preferences.getNotificationPreferences", "data-access.createNotification"]}},
"attendance": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.attendanceRecords", "db.schema.attendanceRules", "db.schema.classEnrollments", "db.schema.users", "db.schema.classes", "types.permissions", "types.action-state", "types.DataScope"], "auth": ["auth"], "classes": ["data-access.getTeacherClasses", "data-access.getAdminClasses"]}},
"scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}},
"diagnostic": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.knowledgePointMastery", "db.schema.learningDiagnosticReports", "db.schema.knowledgePoints", "db.schema.questionsToKnowledgePoints", "db.schema.examSubmissions", "db.schema.submissionAnswers", "db.schema.classEnrollments", "db.schema.classes", "db.schema.users", "types.permissions", "types.action-state", "hooks.usePermission", "components.ui.*"], "auth": ["auth"]}},
@@ -1823,19 +1890,74 @@
"trigger": "push/PR to main",
"steps": ["checkout", "cache npm", "configure npm proxy", "npm ci", "lint", "typecheck", "install playwright chromium", "integration tests", "e2e tests", "cache next.js build", "build", "prepare standalone", "deploy to docker"]
},
"security-audit": {
"security-scan": {
"runsOn": "ubuntu-latest",
"trigger": "push/PR to main",
"steps": ["checkout", "setup node 20", "npm ci", "npm audit --audit-level=moderate (continue-on-error)", "npm audit --audit-level=critical", "upload audit-report.json artifact"]
"needs": "build-deploy",
"continueOnError": true,
"steps": ["checkout", "setup node 20", "npm ci", "npm audit --audit-level=moderate + 生成 audit-report.json (continue-on-error)", "Snyk scan --severity-threshold=high --sarif-file-output=snyk.sarif (env SNYK_TOKEN, continue-on-error)", "Trivy fs scan json+table (continue-on-error)", "OWASP ZAP baseline scan target=NEXTAUTH_URL||localhost:8015 cmd_options='-a -j' (continue-on-error)", "upload security-reports artifact (audit-report.json, trivy-fs-report.json, snyk.sarif)"]
},
"scheduled-backup": {
"runsOn": "ubuntu-latest",
"trigger": "schedule cron 0 2 * * *",
"condition": "github.event_name == 'schedule'",
"steps": ["checkout", "run scripts/backup-db.sh (env DATABASE_URL, BACKUP_DIR)", "upload backups/ artifact (retention 30 days)"]
"steps": ["checkout", "run scripts/backup-db.sh (env DATABASE_URL, BACKUP_DIR)", "run scripts/backup-verify.sh (校验备份完整性)", "run scripts/backup-offsite-sync.sh (异地同步, env BACKUP_OFFSITE_*, 失败不阻塞)", "upload backups/ artifact (retention 30 days)"]
},
"backup-verify": {
"runsOn": "ubuntu-latest",
"trigger": "schedule",
"condition": "github.event_name == 'schedule'",
"needs": "scheduled-backup",
"steps": ["checkout", "download db-backup artifact", "run scripts/backup-verify.sh (独立校验)", "run scripts/health-check.sh > health-report.json", "upload backup-verify-report artifact (backups/, health-report.json, retention 7 days)"]
},
"weekly-dr-drill": {
"runsOn": "ubuntu-latest",
"trigger": "schedule (每周触发, github.run_attempt % 7 == 0)",
"condition": "github.event_name == 'schedule' && github.run_attempt % 7 == 0",
"needs": "backup-verify",
"steps": ["checkout", "run scripts/dr-drill.sh (env DATABASE_URL, DR_DRILL_TEST_DB=next_edu_dr_drill)", "upload dr-drill-report artifact (docs/dr/reports/, retention 90 days)"]
}
}
},
"drDrillWorkflow": {
"configFile": ".gitea/workflows/dr-drill.yml",
"triggers": ["schedule cron 0 4 * * 1 (每周一凌晨 4 点)", "workflow_dispatch (inputs: backup_file, no_cleanup)"],
"job": "dr-drill",
"runsOn": "ubuntu-latest",
"timeoutMinutes": 30,
"steps": [
"checkout",
"install mysql-client",
"prepare backup directory (mkdir backups docs/dr/reports)",
"download db-backup artifact (continue-on-error) 或现场执行 backup-db.sh",
"run scripts/dr-drill.sh (支持 --backup/--no-cleanup 参数)",
"upload dr-drill-report-${{ github.run_id }} artifact (docs/dr/reports/, retention 90 days)",
"on failure: webhook 通知运维团队 (DR_NOTIFICATION_WEBHOOK)"
]
},
"securityWorkflow": {
"configFile": ".gitea/workflows/security.yml",
"triggers": ["schedule cron 0 3 * * 1 (每周一凌晨 3 点)", "workflow_dispatch (inputs: target_url, skip_dast)"],
"job": "deep-security-scan",
"runsOn": "ubuntu-latest",
"continueOnError": true,
"steps": [
"checkout",
"setup node 20",
"npm ci",
"npm audit + 生成 audit-report.json (依赖扫描)",
"Snyk scan --severity-threshold=medium --sarif-file-output=snyk.sarif (env SNYK_TOKEN, 深度依赖+静态分析)",
"Trivy fs scan json+table (文件系统扫描, trivy-fs-report.json)",
"Build Next.js standalone + docker build nextjs-app:scan + Trivy image scan (容器镜像扫描, trivy-image-report.json)",
"OWASP ZAP baseline scan (DAST, target=inputs.target_url||NEXTAUTH_URL||localhost:8015, 可通过 skip_dast 跳过)",
"Generate security-summary.md (jq 汇总各报告漏洞计数)",
"upload security-reports-full artifact (audit-report.json, trivy-fs-report.json, trivy-image-report.json, snyk.sarif, security-summary.md)"
],
"configFiles": {
"suppressions": ".gitea/suppressions.json (Snyk 漏洞抑制, 每条含 id/package/severity/reason/expires/owner)",
"trivyignore": ".trivyignore (Trivy CVE 忽略列表, 每行一个 CVE 带注释)"
}
},
"scripts": {
"scripts/audit.sh": {
"type": "bash",
@@ -1862,22 +1984,93 @@
"scripts/test-backup.sh": {
"type": "bash",
"purpose": "备份流程测试,执行一次备份并验证最新备份文件"
},
"scripts/backup-verify.sh": {
"type": "bash",
"purpose": "备份完整性校验:检查文件存在/大小/gzip 完整性/SQL 内容结构/SQL 语法(可选,需 DATABASE_URL",
"env": ["BACKUP_DIR", "DATABASE_URL", "BACKUP_VERIFY_MIN_SIZE"],
"exitCodes": {"0": "校验通过", "1": "校验失败"},
"options": ["--min-size BYTES", "--no-sql-check", "--help"]
},
"scripts/backup-offsite-sync.sh": {
"type": "bash",
"purpose": "异地备份同步:支持 S3/OSS/NFS 后端,同步后校验文件数量,清理远程过期备份(保留 90 天)",
"env": ["BACKUP_DIR", "BACKUP_OFFSITE_BACKEND", "BACKUP_OFFSITE_REMOTE", "BACKUP_OFFSITE_BUCKET", "BACKUP_OFFSITE_ACCESS_KEY", "BACKUP_OFFSITE_SECRET_KEY", "BACKUP_OFFSITE_REGION", "BACKUP_OFFSITE_RETENTION_DAYS"],
"tools": ["aws-cli (s3)", "rclone (s3/oss)", "ossutil (oss)", "rsync (nfs)"],
"exitCodes": {"0": "同步成功", "1": "同步失败"},
"options": ["--backend TYPE", "--no-cleanup", "--no-verify", "--help"]
},
"scripts/dr-drill.sh": {
"type": "bash",
"purpose": "灾备演练:创建测试库→从备份恢复→数据完整性检查→冒烟测试→清理→生成报告到 docs/dr/reports/",
"env": ["DATABASE_URL", "BACKUP_DIR", "DR_DRILL_TEST_DB", "DR_DRILL_REPORT_DIR"],
"exitCodes": {"0": "演练成功", "1": "演练失败"},
"options": ["--backup FILE", "--test-db NAME", "--no-cleanup", "--report-dir DIR", "--help"]
},
"scripts/dr-drill.ps1": {
"type": "powershell",
"purpose": "灾备演练Windows PowerShell 5.1+ 版本),功能同 Bash 版本",
"env": ["DATABASE_URL", "BACKUP_DIR", "DR_DRILL_TEST_DB", "DR_DRILL_REPORT_DIR"],
"platform": "Windows",
"options": ["-BackupFile FILE", "-TestDb NAME", "-NoCleanup", "-ReportDir DIR", "-Help"]
},
"scripts/failover.sh": {
"type": "bash",
"purpose": "故障切换:检测主库健康→提升备库→更新应用配置→重启应用→验证切换",
"env": ["DATABASE_URL", "DATABASE_URL_STANDBY", "FAILOVER_APP_URL", "FAILOVER_APP_NAME", "FAILOVER_CONFIG_FILE", "FAILOVER_LOG_FILE"],
"exitCodes": {"0": "切换成功", "1": "切换失败"},
"options": ["--auto", "--primary URL", "--standby URL", "--app-url URL", "--no-restart", "--dry-run", "--help"]
},
"scripts/health-check.sh": {
"type": "bash",
"purpose": "健康检查:检查应用 HTTP/数据库连接/磁盘空间/备份新鲜度,输出 JSON 报告",
"env": ["DATABASE_URL", "HEALTH_CHECK_URL", "BACKUP_DIR", "HEALTH_CHECK_DISK_THRESHOLD", "HEALTH_CHECK_BACKUP_MAX_AGE"],
"exitCodes": {"0": "健康", "1": "异常"},
"options": ["--app-url URL", "--no-app", "--no-db", "--no-disk", "--no-backup", "--disk-threshold PCT", "--backup-max-age HRS", "--help"]
}
},
"packageJsonScripts": {
"audit": "npm audit --audit-level=moderate",
"audit:report": "npm audit --json > audit-report.json",
"security:audit": "npm audit --audit-level=moderate",
"security:scan": "bash scripts/security-scan.sh",
"backup": "bash scripts/backup-db.sh",
"restore": "bash scripts/restore-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",
"dr:drill:ps1": "powershell -ExecutionPolicy Bypass -File scripts/dr-drill.ps1",
"dr:health-check": "bash scripts/health-check.sh",
"dr:failover": "bash scripts/failover.sh"
},
"drDocs": {
"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 生成)"
},
"drEnvVars": {
"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"
},
"gitignore": {
"added": ["/backups/", "/audit-report.json", "/playwright-report/", "/test-results/"]
"added": ["/backups/", "/audit-report.json", "/trivy-fs-report.json", "/trivy-image-report.json", "/snyk.sarif", "/security-summary.md", "/playwright-report/", "/test-results/", "/tests/visual/.auth/"],
"exceptions": [".env.example (灾备环境变量示例,允许提交)"]
}
},
"testing": {
"e2e": {
"configFile": "playwright.config.ts",
"testDir": "./tests/e2e",
"testDir": "./tests",
"baseURL": "http://127.0.0.1:3000",
"webServer": {
"command": "npm run dev",
@@ -1891,7 +2084,15 @@
"DATABASE_URL": "mysql://test:test@127.0.0.1:3306/test_db"
}
},
"projects": [{"name": "chromium", "channel": "CI: undefined, local: chrome"}],
"projects": [
{"name": "chromium", "testDir": "./tests/e2e", "channel": "CI: undefined, local: chrome"},
{"name": "visual-chromium", "testDir": "./tests/visual", "channel": "CI: undefined, local: chrome"}
],
"snapshotPathTemplate": "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
"expect": {
"toHaveScreenshot": {"maxDiffPixelRatio": 0.01, "animations": "disabled", "caret": "hide"},
"toMatchSnapshot": {"maxDiffPixelRatio": 0.01}
},
"retries": "CI: 2, local: 0",
"workers": "CI: 2, local: default",
"testFiles": {
@@ -1903,6 +2104,37 @@
"announcements.spec.ts": {"coverage": "公告页面未认证重定向 + 登录后渲染", "requiresDb": "partial"},
"grades.spec.ts": {"coverage": "成绩页面未认证重定向 + 登录后渲染", "requiresDb": "partial"}
}
},
"visual": {
"configFile": "tests/visual/visual.config.ts",
"snapshotDir": "tests/visual/__screenshots__",
"storageStateDir": "tests/visual/.auth/",
"viewports": {
"desktop": {"width": 1920, "height": 1080},
"tablet": {"width": 768, "height": 1024},
"mobile": {"width": 375, "height": 812}
},
"themes": ["light", "dark"],
"defaultMaxDiffPixelRatio": 0.01,
"testAccounts": {
"admin": {"email": "admin@xiaoxue.edu.cn", "envVars": ["VISUAL_ADMIN_EMAIL", "VISUAL_ADMIN_PASSWORD"]},
"teacher": {"email": "admin@xiaoxue.edu.cn", "envVars": ["VISUAL_TEACHER_EMAIL", "VISUAL_TEACHER_PASSWORD"]},
"student": {"email": "admin@xiaoxue.edu.cn", "envVars": ["VISUAL_STUDENT_EMAIL", "VISUAL_STUDENT_PASSWORD"]}
},
"testFiles": {
"homepage.spec.ts": {"coverage": "登录页在 desktop/tablet/mobile × light/dark 下的快照", "requiresDb": false},
"admin-dashboard.spec.ts": {"coverage": "管理员仪表盘整页 + 侧边栏/主内容区组件快照", "requiresDb": true, "role": "admin"},
"teacher-dashboard.spec.ts": {"coverage": "教师仪表盘整页 + 侧边栏/主内容区组件快照", "requiresDb": true, "role": "teacher"},
"student-dashboard.spec.ts": {"coverage": "学生仪表盘整页 + 侧边栏/主内容区组件快照", "requiresDb": true, "role": "student"}
},
"helpers": {
"auth.ts": ["setupAuthState(role)", "loginByUI(page, role)", "storageStatePath(role)"],
"visual-helpers.ts": ["setViewport(page, size)", "setTheme(page, theme)", "waitForPageReady(page)", "maskDynamicElements(page, selectors)", "buildMaskOption(masks)"]
},
"scripts": {
"test:visual": "playwright test --project=visual-chromium",
"test:visual:update": "playwright test --project=visual-chromium --update-snapshots"
}
}
}
}

362
docs/dr/dr-plan.md Normal file
View File

@@ -0,0 +1,362 @@
# 灾备计划 (Disaster Recovery Plan)
> **文档版本**: 1.0
> **最后更新**: 2026-06-17
> **审核周期**: 每季度审核一次
---
## 1. 概述
本文档定义了 Next_Edu 系统的灾备策略、恢复目标、备份方案和故障切换流程,确保在发生灾难性故障时能够快速恢复服务并最小化数据丢失。
### 1.1 适用范围
- 生产环境数据库(MySQL)
- 应用服务(Next.js)
- 备份文件(本地 + 异地)
- CI/CD 流水线
### 1.2 关键指标
| 指标 | 目标 | 说明 |
|------|------|------|
| **RTO** (Recovery Time Objective) | 4 小时 | 从故障发生到服务恢复的最长时间 |
| **RPO** (Recovery Point Objective) | 24 小时 | 最大可接受的数据丢失时间窗口 |
---
## 2. RTO/RPO 定义
### 2.1 RTO(恢复时间目标): 4 小时
**定义**: 从系统故障发生到服务完全恢复的最长允许时间。
**分解**:
| 阶段 | 预计耗时 | 说明 |
|------|---------|------|
| 故障检测 | 5 分钟 | 健康检查脚本自动检测 |
| 通知与决策 | 15 分钟 | 通知运维团队,决定是否切换 |
| 执行恢复 | 60 分钟 | 从备份恢复数据库 |
| 应用重启 | 10 分钟 | 重启应用并验证 |
| 数据验证 | 30 分钟 | 验证数据完整性 |
| 流量恢复 | 10 分钟 | 逐步恢复用户流量 |
| 缓冲时间 | 90 分钟 | 应对意外情况 |
| **总计** | **≤ 4 小时** | |
### 2.2 RPO(恢复点目标): 24 小时
**定义**: 最大可接受的数据丢失时间窗口。
**保障措施**:
- 每日凌晨 2 点全量备份(cron: `0 2 * * *`)
- 备份后自动校验完整性
- 备份后自动同步到异地存储
- 最坏情况下丢失不超过 24 小时数据
---
## 3. 备份策略
### 3.1 备份频率
| 备份类型 | 频率 | 时间 | 保留期 |
|---------|------|------|--------|
| 全量备份 | 每日 | 凌晨 2:00 (CST) | 本地 30 天,异地 90 天 |
| 异地同步 | 每日(备份后) | 凌晨 2:30 (CST) | 90 天 |
### 3.2 备份内容
- **数据库**: 使用 `mysqldump` 导出全部数据库,`gzip` 压缩
- **格式**: `db_backup_YYYYMMDD_HHMMSS.sql.gz`
- **存储位置**:
- 本地: `./backups/`
- 异地: S3/OSS/NFS(根据 `BACKUP_OFFSITE_BACKEND` 配置)
### 3.3 备份验证
每次备份后自动执行校验:
1. 文件存在性检查
2. 文件大小检查(最小 1KB)
3. gzip 完整性校验(`gunzip -t`)
4. SQL 内容结构检查(mysqldump 头部、语句数量)
5. SQL 语法校验(可选,需 `DATABASE_URL`)
### 3.4 备份保留策略
| 存储位置 | 保留期 | 清理方式 |
|---------|--------|---------|
| 本地 (`./backups/`) | 30 天 | `find -mtime +30 -delete` |
| 异地 (S3/OSS/NFS) | 90 天 | `backup-offsite-sync.sh` 自动清理 |
---
## 4. 故障切换流程
### 4.1 故障检测
1. **自动检测**: `health-check.sh` 定期运行,检查:
- 应用 HTTP 健康端点
- 数据库连接
- 磁盘空间
- 备份新鲜度
2. **手动报告**: 用户反馈、监控系统告警
### 4.2 故障切换步骤
```
┌─────────────────┐
│ 1. 检测故障 │ 健康检查失败 / 用户报告
└────────┬────────┘
┌─────────────────┐
│ 2. 通知运维 │ 电话/邮件/即时通讯通知运维团队
└────────┬────────┘
┌─────────────────┐
│ 3. 决策(5分钟) │ 评估故障严重程度,决定是否切换
└────────┬────────┘
┌────┴────┐
│ │
切换 不切换
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│4. 执行 │ │ 修复主库 │
│ 切换 │ │ │
└────┬────┘ └─────────┘
┌─────────┐
│5. 验证 │ 健康检查、功能测试
│ 恢复 │
└────┬────┘
┌─────────┐
│6. 事后 │ 记录事件、复盘改进
│ 复盘 │
└─────────┘
```
### 4.3 执行故障切换
使用 `failover.sh` 脚本:
```bash
# 手动模式(交互式确认)
./scripts/failover.sh
# 半自动模式(检测到故障后自动切换,需确认)
./scripts/failover.sh --auto
# 演练模式(不实际执行)
./scripts/failover.sh --dry-run
# 指定备库
./scripts/failover.sh --standby "mysql://user:pass@standby-host:3306/dbname"
```
**前提条件**:
- 配置 `DATABASE_URL_STANDBY` 环境变量
- 备库已配置主从复制(如果是主从架构)
- 应用容器可通过 Docker 重启
---
## 5. 灾备演练
### 5.1 演练频率
| 演练类型 | 频率 | 触发方式 |
|---------|------|---------|
| 自动演练 | 每周一次 | CI 定时任务(每周一凌晨 4 点) |
| 手动演练 | 每月一次 | 运维人员手动触发 |
| 全量演练 | 每季度一次 | 完整故障切换演练 |
### 5.2 演练内容
1. **创建测试数据库** (`next_edu_dr_drill`)
2. **从最新备份恢复** 到测试数据库
3. **数据完整性检查**:
- 表数量对比(测试库 vs 源库)
- 记录数对比
4. **冒烟测试**:
- 基础表查询
- 关键业务表查询(users, schools)
5. **清理测试数据库**
6. **生成演练报告**
### 5.3 演练脚本
```bash
# Bash 版本(Linux/macOS)
./scripts/dr-drill.sh
# PowerShell 版本(Windows)
.\scripts\dr-drill.ps1
# 指定备份文件
./scripts/dr-drill.sh --backup backups/db_backup_20260617_020000.sql.gz
# 保留测试数据库(用于调试)
./scripts/dr-drill.sh --no-cleanup
```
### 5.4 演练报告
- **存储位置**: `docs/dr/reports/`
- **格式**: Markdown
- **内容**: 演练时间、步骤结果、RTO 评估、数据完整性指标
- **保留期**: 90 天(CI artifact)
---
## 6. 联系人列表
> **注意**: 以下为模板,请根据实际人员填写
### 6.1 主要联系人
| 角色 | 姓名 | 电话 | 邮箱 | 职责 |
|------|------|------|------|------|
| 主负责人 | [待填写] | [待填写] | [待填写] | 灾备决策、协调 |
| 备份负责人 | [待填写] | [待填写] | [待填写] | 备份执行、监控 |
| DBA | [待填写] | [待填写] | [待填写] | 数据库恢复 |
| 运维工程师 | [待填写] | [待填写] | [待填写] | 应用部署、网络 |
| 开发负责人 | [待填写] | [待填写] | [待填写] | 代码修复、功能验证 |
### 6.2 升级路径
1. **L1**: 运维工程师(5 分钟内响应)
2. **L2**: 主负责人 + DBA(15 分钟内响应)
3. **L3**: 全体联系人(30 分钟内响应)
---
## 7. 恢复步骤
### 7.1 从备份恢复数据库
```bash
# 1. 获取最新备份
LATEST_BACKUP=$(ls -t backups/db_backup_*.sql.gz | head -1)
echo "Using backup: $LATEST_BACKUP"
# 2. 校验备份完整性
./scripts/backup-verify.sh "$LATEST_BACKUP"
# 3. 恢复数据库
./scripts/restore-db.sh "$LATEST_BACKUP"
# 4. 验证恢复结果
mysql -u root -p -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='next_edu';"
```
### 7.2 完整恢复流程
1. **获取最新备份**
- 本地: `./backups/`
- 异地: 从 S3/OSS/NFS 下载
- CI artifact: 从 Gitea Actions 下载
2. **恢复数据库**
```bash
./scripts/restore-db.sh backups/db_backup_YYYYMMDD_HHMMSS.sql.gz
```
3. **重启应用**
```bash
docker restart nextjs-app
# 或
docker stop nextjs-app && docker rm nextjs-app
# 重新部署
```
4. **验证数据完整性**
```bash
# 运行健康检查
./scripts/health-check.sh
# 运行灾备演练(对比数据)
./scripts/dr-drill.sh --no-cleanup
```
5. **恢复流量**
- 验证应用功能正常
- 逐步恢复用户流量
- 监控系统指标
---
## 8. 监控与告警
### 8.1 健康检查
```bash
# 手动运行健康检查
./scripts/health-check.sh
# 输出 JSON 格式报告
./scripts/health-check.sh > health-report.json
```
**检查项**:
- 应用 HTTP 健康端点
- 数据库连接
- 磁盘空间(阈值 90%)
- 备份新鲜度(24 小时内)
### 8.2 告警条件
| 条件 | 严重级别 | 通知方式 |
|------|---------|---------|
| 应用不可达 | 严重 | 电话 + 邮件 |
| 数据库连接失败 | 严重 | 电话 + 邮件 |
| 磁盘空间 > 90% | 警告 | 邮件 |
| 备份超过 24 小时 | 警告 | 邮件 |
| 备份校验失败 | 严重 | 电话 + 邮件 |
| 灾备演练失败 | 警告 | 邮件 |
---
## 9. 环境变量配置
```bash
# 灾备配置
BACKUP_OFFSITE_BACKEND=none # s3|oss|nfs|none
BACKUP_OFFSITE_REMOTE= # 远程路径
BACKUP_OFFSITE_BUCKET= # 存储桶名
BACKUP_OFFSITE_ACCESS_KEY= # 访问密钥
BACKUP_OFFSITE_SECRET_KEY= # 秘密密钥
BACKUP_OFFSITE_REGION=us-east-1 # 区域
DR_DRILL_TEST_DB=next_edu_dr_drill # 演练测试数据库
HEALTH_CHECK_URL=http://localhost:8015 # 健康检查 URL
# 故障切换配置
DATABASE_URL_STANDBY= # 备库连接 URL
FAILOVER_APP_NAME=nextjs-app # 应用容器名
FAILOVER_APP_URL=http://localhost:8015 # 应用 URL
```
---
## 10. 文档维护
- **审核周期**: 每季度审核一次
- **更新触发**: 系统架构变更、联系人变更、演练发现问题
- **关联文档**:
- `docs/dr/dr-runbook.md` - 灾备操作手册
- `docs/dr/reports/` - 演练报告存档
- `scripts/` - 灾备相关脚本
---
## 11. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|------|------|---------|--------|
| 2026-06-17 | 1.0 | 初始版本 | - |

699
docs/dr/dr-runbook.md Normal file
View File

@@ -0,0 +1,699 @@
# 灾备操作手册 (DR Runbook)
> **文档版本**: 1.0
> **最后更新**: 2026-06-17
> **适用场景**: 生产环境故障处理
---
## 概述
本手册提供常见故障场景的诊断和处理步骤。每个场景包含:症状、诊断、处理步骤、验证方法。
**紧急联系**: 参见 `docs/dr/dr-plan.md` 第 6 节联系人列表
---
## 场景 1: 数据库故障
### 1.1 数据库不可达
#### 症状
- 应用报错: `ECONNREFUSED``Connection refused`
- 健康检查 `database` 状态为 `fail`
- 用户无法登录、查询数据
#### 诊断
```bash
# 1. 检查数据库连接
mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SELECT 1;"
# 2. 检查数据库进程
systemctl status mysql
# 或 Docker 环境
docker ps | grep mysql
# 3. 检查端口
telnet <DB_HOST> <DB_PORT>
# 或
nc -zv <DB_HOST> <DB_PORT>
# 4. 查看数据库日志
tail -100 /var/log/mysql/error.log
# 或 Docker
docker logs <mysql_container> --tail 100
```
#### 处理步骤
**情况 A: 数据库服务停止**
```bash
# 重启数据库服务
sudo systemctl restart mysql
# 或 Docker
docker restart <mysql_container>
# 等待启动完成
sleep 10
mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SELECT 1;"
```
**情况 B: 数据库无法启动**
```bash
# 1. 检查磁盘空间
df -h
# 2. 检查配置文件
mysql --verbose --help | head -20
# 3. 如果磁盘满,清理空间
sudo find /var/log -type f -name "*.log" -mtime +7 -delete
# 4. 如果配置错误,恢复备份配置
sudo cp /etc/mysql/my.cnf.bak /etc/mysql/my.cnf
sudo systemctl restart mysql
```
**情况 C: 数据库损坏,需要从备份恢复**
```bash
# 1. 获取最新备份
LATEST_BACKUP=$(ls -t backups/db_backup_*.sql.gz | head -1)
echo "Using backup: $LATEST_BACKUP"
# 2. 校验备份
./scripts/backup-verify.sh "$LATEST_BACKUP"
# 3. 恢复数据库
./scripts/restore-db.sh "$LATEST_BACKUP"
# 4. 重启应用
docker restart nextjs-app
```
**情况 D: 主库故障,需要切换到备库**
```bash
# 1. 执行故障切换(手动模式)
./scripts/failover.sh
# 2. 或半自动模式
./scripts/failover.sh --auto
# 3. 验证切换结果
./scripts/health-check.sh
```
#### 验证
```bash
# 1. 运行健康检查
./scripts/health-check.sh
# 2. 验证应用功能
curl -f http://localhost:8015
# 3. 验证数据库查询
mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SELECT COUNT(*) FROM users;"
# 4. 运行灾备演练验证数据完整性
./scripts/dr-drill.sh
```
---
### 1.2 数据库性能问题
#### 症状
- 应用响应缓慢
- 查询超时
- CPU/内存使用率高
#### 诊断
```bash
# 1. 查看当前连接
mysql -e "SHOW PROCESSLIST;"
# 2. 查看慢查询
mysql -e "SHOW VARIABLES LIKE 'slow_query%';"
tail -100 /var/log/mysql/slow.log
# 3. 查看系统资源
top
iostat -x 1
```
#### 处理步骤
```bash
# 1. 终止长时间运行的查询
mysql -e "KILL <process_id>;"
# 2. 优化表
mysql -e "OPTIMIZE TABLE <table_name>;"
# 3. 重启数据库(如果必要)
sudo systemctl restart mysql
```
#### 验证
```bash
# 监控性能指标
mysql -e "SHOW STATUS LIKE 'Threads%';"
mysql -e "SHOW STATUS LIKE 'Slow_queries';"
```
---
## 场景 2: 应用故障
### 2.1 应用不可达
#### 症状
- HTTP 502/503 错误
- 页面无法访问
- 健康检查 `app` 状态为 `fail`
#### 诊断
```bash
# 1. 检查应用容器
docker ps | grep nextjs-app
# 2. 查看应用日志
docker logs nextjs-app --tail 100
# 3. 检查端口
netstat -tlnp | grep 8015
# 4. 检查健康端点
curl -v http://localhost:8015
```
#### 处理步骤
**情况 A: 容器停止**
```bash
# 启动容器
docker start nextjs-app
# 等待启动
sleep 10
curl -f http://localhost:8015
```
**情况 B: 容器崩溃,需要重启**
```bash
# 重启容器
docker restart nextjs-app
# 如果重启失败,重新部署
docker stop nextjs-app || true
docker rm nextjs-app || true
# 重新运行部署流程(参见 CI/CD)
```
**情况 C: 应用配置错误**
```bash
# 1. 检查环境变量
docker exec nextjs-app env | grep DATABASE_URL
# 2. 检查 .env.local
cat .env.local
# 3. 修正配置后重启
docker restart nextjs-app
```
#### 验证
```bash
# 1. 健康检查
./scripts/health-check.sh
# 2. 功能测试
curl -f http://localhost:8015
curl -f http://localhost:8015/api/auth/providers
# 3. 查看日志确认无错误
docker logs nextjs-app --tail 20
```
---
### 2.2 应用 OOM(内存不足)
#### 症状
- 容器被 OOM Killer 终止
- 日志中出现 `JavaScript heap out of memory`
#### 诊断
```bash
# 1. 查看容器状态
docker inspect nextjs-app | grep -A 5 "State"
# 2. 查看内存使用
docker stats nextjs-app
# 3. 查看系统日志
dmesg | grep -i "oom"
```
#### 处理步骤
```bash
# 1. 增加 Node.js 内存限制
docker stop nextjs-app
docker rm nextjs-app
docker run -d \
--init \
-p 8015:3000 \
--restart unless-stopped \
--name nextjs-app \
--network 1panel-network \
-e NODE_OPTIONS="--max-old-space-size=2048" \
-e NODE_ENV=production \
-e DATABASE_URL=$DATABASE_URL \
-e NEXTAUTH_SECRET=$NEXTAUTH_SECRET \
-e NEXTAUTH_URL=$NEXTAUTH_URL \
nextjs-app
# 2. 或增加容器内存限制
docker run -d --memory=2g ...
```
#### 验证
```bash
# 监控内存使用
docker stats nextjs-app
# 确认应用正常
curl -f http://localhost:8015
```
---
## 场景 3: 备份失败
### 3.1 定时备份未执行
#### 症状
- 健康检查 `backup` 状态为 `fail`(备份超过 24 小时)
- `./backups/` 目录无新文件
- CI 中 `scheduled-backup` job 失败
#### 诊断
```bash
# 1. 检查最新备份
ls -lt backups/db_backup_*.sql.gz | head -5
# 2. 检查 CI 运行记录
# 访问 Gitea Actions 页面查看 scheduled-backup job
# 3. 手动运行备份测试
./scripts/backup-db.sh
# 4. 检查磁盘空间
df -h
```
#### 处理步骤
**情况 A: 磁盘空间不足**
```bash
# 1. 清理旧备份
find backups/ -name "db_backup_*.sql.gz" -mtime +30 -delete
# 2. 清理其他临时文件
find /tmp -type f -mtime +7 -delete
# 3. 重新运行备份
./scripts/backup-db.sh
```
**情况 B: 数据库连接问题**
```bash
# 1. 验证数据库连接
mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SELECT 1;"
# 2. 检查 DATABASE_URL 环境变量
echo $DATABASE_URL
# 3. 修正配置后重新备份
export DATABASE_URL="mysql://correct_url"
./scripts/backup-db.sh
```
**情况 C: mysqldump 权限问题**
```bash
# 1. 检查用户权限
mysql -u <DB_USER> -p -e "SHOW GRANTS;"
# 2. 授予必要权限
mysql -u root -p -e "GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON *.* TO '<DB_USER>'@'%';"
FLUSH PRIVILEGES;
# 3. 重新备份
./scripts/backup-db.sh
```
#### 验证
```bash
# 1. 确认新备份存在
ls -lt backups/db_backup_*.sql.gz | head -1
# 2. 校验备份完整性
./scripts/backup-verify.sh
# 3. 运行健康检查
./scripts/health-check.sh
```
---
### 3.2 备份文件损坏
#### 症状
- `backup-verify.sh` 校验失败
- gzip 解压失败
- SQL 文件内容异常
#### 诊断
```bash
# 1. 运行校验脚本
./scripts/backup-verify.sh backups/db_backup_YYYYMMDD_HHMMSS.sql.gz
# 2. 手动检查 gzip
gunzip -t backups/db_backup_YYYYMMDD_HHMMSS.sql.gz
# 3. 检查文件大小
ls -lh backups/db_backup_*.sql.gz
```
#### 处理步骤
```bash
# 1. 删除损坏的备份
rm backups/db_backup_YYYYMMDD_HHMMSS.sql.gz
# 2. 重新执行备份
./scripts/backup-db.sh
# 3. 校验新备份
./scripts/backup-verify.sh
# 4. 如果新备份也损坏,检查数据库完整性
mysql -e "CHECK TABLE users; CHECK TABLE schools;"
```
#### 验证
```bash
# 1. 校验新备份
./scripts/backup-verify.sh
# 2. 运行灾备演练
./scripts/dr-drill.sh
```
---
## 场景 4: 异地同步失败
### 4.1 S3/OSS 同步失败
#### 症状
- `backup-offsite-sync.sh` 失败
- CI 中 "Sync backup to offsite storage" 步骤失败
- 异地存储缺少最新备份
#### 诊断
```bash
# 1. 检查后端配置
echo $BACKUP_OFFSITE_BACKEND
echo $BACKUP_OFFSITE_REMOTE
echo $BACKUP_OFFSITE_BUCKET
# 2. 检查凭证
echo $BACKUP_OFFSITE_ACCESS_KEY
echo $BACKUP_OFFSITE_SECRET_KEY
# 3. 测试连接
aws s3 ls s3://$BACKUP_OFFSITE_BUCKET/ # S3
# 或
ossutil ls oss://$BACKUP_OFFSITE_BUCKET/ # OSS
# 4. 手动运行同步
./scripts/backup-offsite-sync.sh
```
#### 处理步骤
**情况 A: 凭证错误**
```bash
# 1. 更新凭证
export BACKUP_OFFSITE_ACCESS_KEY="new_access_key"
export BACKUP_OFFSITE_SECRET_KEY="new_secret_key"
# 2. 更新 Gitea Secrets
# 访问仓库 Settings > Secrets 更新对应 secret
# 3. 重新同步
./scripts/backup-offsite-sync.sh
```
**情况 B: 网络问题**
```bash
# 1. 测试网络连通性
ping s3.amazonaws.com # S3
ping oss-cn-beijing.aliyuncs.com # OSS
# 2. 检查代理设置
echo $http_proxy
echo $https_proxy
# 3. 配置代理后重试
export http_proxy=http://proxy:port
export https_proxy=http://proxy:port
./scripts/backup-offsite-sync.sh
```
**情况 C: 工具未安装**
```bash
# 1. 安装 aws-cli
pip install awscli
# 或
apt-get install -y awscli
# 2. 安装 rclone
curl https://rclone.org/install.sh | sudo bash
# 3. 重新同步
./scripts/backup-offsite-sync.sh
```
#### 验证
```bash
# 1. 列出远程文件
aws s3 ls s3://$BACKUP_OFFSITE_BUCKET/backups/
# 或
rclone lsf $BACKUP_OFFSITE_REMOTE
# 2. 对比本地和远程文件数量
LOCAL_COUNT=$(ls backups/db_backup_*.sql.gz | wc -l)
REMOTE_COUNT=$(aws s3 ls s3://$BACKUP_OFFSITE_BUCKET/backups/ | grep -c "db_backup")
echo "Local: $LOCAL_COUNT, Remote: $REMOTE_COUNT"
```
---
### 4.2 NFS 同步失败
#### 症状
- `backup-offsite-sync.sh` NFS 后端失败
- NFS 目录不可写
#### 诊断
```bash
# 1. 检查 NFS 挂载
mount | grep nfs
# 2. 检查目录权限
ls -la $BACKUP_OFFSITE_REMOTE
# 3. 测试写入
touch $BACKUP_OFFSITE_REMOTE/test && rm $BACKUP_OFFSITE_REMOTE/test
```
#### 处理步骤
```bash
# 1. 重新挂载 NFS
sudo umount /mnt/nfs
sudo mount -t nfs <nfs_server>:/path /mnt/nfs
# 2. 检查权限
sudo chown -R $USER:$USER /mnt/nfs/backups
# 3. 重新同步
./scripts/backup-offsite-sync.sh
```
---
## 场景 5: 灾备演练失败
### 5.1 演练恢复失败
#### 症状
- `dr-drill.sh` 步骤 3(恢复)失败
- 测试数据库创建成功但恢复失败
#### 诊断
```bash
# 1. 查看演练报告
cat docs/dr/reports/dr_drill_*.md
# 2. 手动测试恢复
mysql -h <DB_HOST> -u <DB_USER> -p -e "CREATE DATABASE test_manual;"
gunzip -c backups/db_backup_*.sql.gz | mysql -h <DB_HOST> -u <DB_USER> -p test_manual
# 3. 检查备份文件
./scripts/backup-verify.sh
```
#### 处理步骤
```bash
# 1. 清理失败的测试数据库
mysql -h <DB_HOST> -u <DB_USER> -p -e "DROP DATABASE IF EXISTS next_edu_dr_drill;"
# 2. 校验备份
./scripts/backup-verify.sh
# 3. 如果备份损坏,重新备份
./scripts/backup-db.sh
# 4. 重新运行演练
./scripts/dr-drill.sh
```
---
### 5.2 演练后测试数据库未清理
#### 症状
- `next_edu_dr_drill` 数据库残留
- 磁盘空间异常增长
#### 诊断
```bash
# 1. 检查测试数据库
mysql -e "SHOW DATABASES LIKE 'next_edu_dr_drill';"
# 2. 检查数据库大小
mysql -e "SELECT table_schema, SUM(data_length + index_length) / 1024 / 1024 AS size_mb FROM information_schema.tables WHERE table_schema = 'next_edu_dr_drill' GROUP BY table_schema;"
```
#### 处理步骤
```bash
# 1. 手动删除测试数据库
mysql -h <DB_HOST> -u <DB_USER> -p -e "DROP DATABASE IF EXISTS next_edu_dr_drill;"
# 2. 验证已删除
mysql -e "SHOW DATABASES LIKE 'next_edu_dr_drill';"
```
---
## 场景 6: 磁盘空间不足
#### 症状
- 健康检查 `disk` 状态为 `fail`
- 应用或数据库写入失败
- 系统响应缓慢
#### 诊断
```bash
# 1. 检查磁盘使用
df -h
# 2. 查找大文件
du -sh /* 2>/dev/null | sort -rh | head -10
du -sh /var/* 2>/dev/null | sort -rh | head -10
# 3. 查找大日志文件
find /var/log -type f -size +100M -exec ls -lh {} \;
```
#### 处理步骤
```bash
# 1. 清理旧备份
find backups/ -name "db_backup_*.sql.gz" -mtime +30 -delete
# 2. 清理日志
sudo find /var/log -type f -name "*.log" -mtime +7 -delete
sudo journalctl --vacuum-time=7d
# 3. 清理 Docker 资源
docker system prune -a --volumes
# 注意: 这会删除未使用的镜像和卷,谨慎使用
# 4. 清理 npm 缓存
npm cache clean --force
# 5. 清理临时文件
find /tmp -type f -mtime +7 -delete
```
#### 验证
```bash
# 1. 检查磁盘空间
df -h
# 2. 运行健康检查
./scripts/health-check.sh
```
---
## 附录: 快速参考命令
### 备份相关
```bash
# 执行备份
npm run backup
# 校验备份
npm run dr:backup-verify
# 异地同步
npm run dr:offsite-sync
# 灾备演练
npm run dr:drill
# 健康检查
npm run dr:health-check
```
### 恢复相关
```bash
# 从备份恢复
./scripts/restore-db.sh backups/db_backup_YYYYMMDD_HHMMSS.sql.gz
# 故障切换
./scripts/failover.sh --auto
```
### 诊断相关
```bash
# 完整健康检查
./scripts/health-check.sh
# 检查数据库
mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SHOW PROCESSLIST;"
# 检查应用日志
docker logs nextjs-app --tail 100
# 检查磁盘空间
df -h
```
---
## 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|------|------|---------|--------|
| 2026-06-17 | 1.0 | 初始版本 | - |

View File

@@ -0,0 +1,238 @@
# 通知渠道集成文档
本模块(`src/modules/notifications`)为系统提供多渠道通知发送能力,支持站内消息、短信、微信公众号模板消息和邮件四种渠道。
## 架构概览
```
调用方 (Server Action / 其他模块)
dispatcher.ts ── 读取用户通知偏好 (notification_preferences)
│ ── 读取用户联系方式 (users.phone / users.email)
├── in_app (站内消息,总是启用)
├── sms (短信smsEnabled && phone)
├── wechat (微信模板消息pushEnabled && openId)
└── email (邮件emailEnabled && email)
data-access.ts ── logNotificationSend (console 日志)
```
## 模块结构
| 文件 | 职责 |
|------|------|
| `types.ts` | 通知渠道类型定义NotificationPayload, ChannelSendResult 等) |
| `channels/types.ts` | 渠道发送者接口NotificationChannelSender, ChannelRecipient |
| `channels/sms-channel.ts` | 短信渠道(阿里云/腾讯云/Mock |
| `channels/wechat-channel.ts` | 微信公众号模板消息渠道 |
| `channels/email-channel.ts` | 邮件渠道Nodemailer SMTP |
| `channels/in-app-channel.ts` | 站内消息渠道(复用 messaging data-access |
| `dispatcher.ts` | 通知分发器(按偏好选择渠道、并行发送) |
| `data-access.ts` | 通知数据访问(偏好查询、联系方式查询、日志记录) |
| `actions.ts` | Server ActionssendNotificationAction, sendClassNotificationAction |
| `index.ts` | 模块统一导出 |
## 渠道配置
### 1. 站内消息in_app
- **默认启用**,无需配置。
- 复用现有 `messaging` 模块的 `createNotification`,写入 `message_notifications` 表。
- 用户可在站内通知中心查看。
### 2. 短信SMS
支持三种 Provider通过 `SMS_PROVIDER` 环境变量选择:
| Provider | 说明 | 环境变量 |
|----------|------|----------|
| `mock`(默认) | 开发环境模拟,仅记录日志 | 无需其他配置 |
| `aliyun` | 阿里云短信 | `SMS_ACCESS_KEY_ID`, `SMS_ACCESS_KEY_SECRET`, `SMS_SIGN_NAME`, `SMS_TEMPLATE_CODE` |
| `tencent` | 腾讯云短信 | 同上(复用相同变量名) |
**模板变量替换**:将 `payload.title` / `payload.content` 填入模板变量 `title` / `content`
### 3. 微信公众号模板消息wechat
通过微信公众号 API 发送模板消息:
1. **获取 access_token**`GET https://api.weixin.qq.com/cgi-bin/token`(带缓存,提前 5 分钟刷新)
2. **发送模板消息**`POST https://api.weixin.qq.com/cgi-bin/message/template/send`
**环境变量**
| 变量 | 说明 |
|------|------|
| `WECHAT_APP_ID` | 公众号 AppID |
| `WECHAT_APP_SECRET` | 公众号 AppSecret |
| `WECHAT_TEMPLATE_ID` | 模板消息 ID |
**模板数据映射**
- `keyword1``payload.title`
- `keyword2``payload.content`
- `keyword3``payload.type`
可通过 `payload.metadata.wechatKeywords` 自定义覆盖。
> **注意**:当前 `users` 表无 `wechat_open_id` 字段,微信渠道暂不会实际触发。扩展 schema 后在 `data-access.ts` 的 `getUserContactInfo` 中补充查询即可。
### 4. 邮件email
使用 Nodemailer 通过 SMTP 发送,支持 HTML 邮件模板(根据通知类型显示不同颜色)。
**环境变量**
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `EMAIL_HOST` | - | SMTP 主机(配置后启用真实发送) |
| `EMAIL_PORT` | `587` | SMTP 端口465 使用 SSL |
| `EMAIL_USER` | - | SMTP 用户名 |
| `EMAIL_PASS` | - | SMTP 密码 |
| `EMAIL_FROM` | `noreply@example.com` | 发件人地址 |
## Mock 模式(开发环境)
所有渠道均提供 Mock 实现,**无需任何外部服务即可运行**
- SMS: `SMS_PROVIDER=mock`(默认)→ 仅 `console.info` 记录
- WeChat: 未配置 `WECHAT_APP_ID` 等 → 自动使用 Mock
- Email: 未配置 `EMAIL_HOST` → 自动使用 Mock
- 站内消息: 始终真实写入数据库(无 Mock
## 生产环境配置
### 阿里云短信示例
```env
SMS_PROVIDER=aliyun
SMS_ACCESS_KEY_ID=LTAI5tXXXXXXXXXXXX
SMS_ACCESS_KEY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXX
SMS_SIGN_NAME=智慧教务
SMS_TEMPLATE_CODE=SMS_123456789
```
### 微信公众号示例
```env
WECHAT_APP_ID=wx1234567890abcdef
WECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
WECHAT_TEMPLATE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 邮件 SMTP 示例
```env
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USER=notification@example.com
EMAIL_PASS=xxxxxxxx
EMAIL_FROM=智慧教务 <notification@example.com>
```
## 使用方式
### 1. 通过 Server Action 调用
```ts
import { sendNotificationAction, sendClassNotificationAction } from "@/modules/notifications/actions"
// 发送给单个用户
await sendNotificationAction({
userId: "user-xxx",
title: "作业提醒",
content: "您有一份新作业待提交",
type: "info",
actionUrl: "/homework/123",
})
// 发送给班级所有学生(教师权限)
await sendClassNotificationAction("class-xxx", {
title: "考试通知",
content: "明天下午 2 点期中考试",
type: "warning",
actionUrl: "/exams/456",
})
```
### 2. 直接调用分发器(服务端)
```ts
import { sendNotification } from "@/modules/notifications"
await sendNotification({
userId: "user-xxx",
title: "成绩发布",
content: "您的数学成绩为 95 分",
type: "success",
})
```
## 渠道选择逻辑
分发器根据用户通知偏好(`notification_preferences` 表)和联系方式决定启用渠道:
| 渠道 | 启用条件 |
|------|----------|
| in_app | `pushEnabled`(默认 true总是兜底 |
| sms | `smsEnabled` && 用户有手机号 |
| email | `emailEnabled` && 用户有邮箱 |
| wechat | `pushEnabled` && 用户有 wechatOpenId |
> 通知偏好中的 `homeworkNotifications` / `gradeNotifications` 等按通知类别控制,由调用方在构造 payload 前决定是否调用发送。
## 扩展新渠道
1.`channels/` 下创建新文件,实现 `NotificationChannelSender` 接口:
```ts
import "server-only"
import type { NotificationChannelSender, ChannelRecipient } from "./types"
import type { NotificationPayload, ChannelSendResult, NotificationChannel } from "../types"
const channel: NotificationChannel = "your_channel" as NotificationChannel
class YourChannelSender implements NotificationChannelSender {
readonly channel = channel
async send(payload: NotificationPayload, recipient: ChannelRecipient): Promise<ChannelSendResult> {
// 实现发送逻辑
}
async sendBatch(items) { /* ... */ }
}
export function createYourSender(): NotificationChannelSender {
return new YourChannelSender()
}
```
2.`types.ts``NotificationChannel` 类型中添加新渠道:
```ts
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat" | "your_channel"
```
3.`dispatcher.ts``SenderRegistry``selectChannels` 中注册新渠道。
4.`index.ts` 中导出新的发送器工厂。
## 权限说明
- `sendNotificationAction`: 需要 `MESSAGE_SEND` 权限
- `sendClassNotificationAction`: 需要 `MESSAGE_SEND` 权限,且教师只能给自己所教班级发送
> 项目无独立 `NOTIFICATION_SEND` 权限点,复用 `MESSAGE_SEND`(教师/管理员/年级主任均拥有)。
## 外部 SDK 依赖
所有外部 SDK 均使用**动态 import**,避免增加构建体积:
| 渠道 | SDK | 安装命令 |
|------|-----|----------|
| 阿里云短信 | `@alicloud/dysmsapi20170525`, `@alicloud/openapi-client`, `@alicloud/credentials` | `npm i @alicloud/dysmsapi20170525 @alicloud/openapi-client @alicloud/credentials` |
| 腾讯云短信 | `tencentcloud-sdk-nodejs` | `npm i tencentcloud-sdk-nodejs` |
| 邮件 | `nodemailer` | `npm i nodemailer @types/nodemailer` |
> **Mock 模式无需安装任何 SDK**,开发环境开箱即用。生产环境按需安装对应 SDK。

152
docs/security/scanning.md Normal file
View File

@@ -0,0 +1,152 @@
# 安全扫描指南
本项目集成了多层安全扫描,覆盖依赖审计、深度依赖分析、静态分析、容器镜像扫描与动态应用安全测试(DAST)。
## 一、CI 中的安全扫描流程
### 1.1 主 CI 流水线(`.gitea/workflows/ci.yml`)
主流水线在 `push`/`pull_request``main` 时触发,包含三个 Job:
| Job | 触发条件 | 说明 |
|-----|---------|------|
| `build-deploy` | push/PR to main | 构建、测试、部署到 Docker |
| `security-scan` | push/PR to main(依赖 build-deploy) | 完整安全扫描,失败不阻塞构建 |
| `scheduled-backup` | schedule cron `0 2 * * *` | 每天凌晨 2 点数据库备份 |
`security-scan` Job 依次执行以下扫描,所有步骤均设置 `continue-on-error: true`,**扫描失败不阻塞构建**,但会生成报告并上传为 artifact(`security-reports`):
1. **npm audit** — 依赖漏洞审计(moderate 级别),生成 `audit-report.json`
2. **Snyk 扫描** — 深度依赖分析(`--severity-threshold=high`),生成 `snyk.sarif`,需配置 `SNYK_TOKEN` secret
3. **Trivy 文件系统扫描** — 扫描项目代码与依赖,生成 `trivy-fs-report.json` 与表格视图
4. **OWASP ZAP 基线扫描** — 对部署后的应用执行 DAST,目标为 `NEXTAUTH_URL` secret 或 `http://localhost:8015`
### 1.2 独立安全工作流(`.gitea/workflows/security.yml`)
独立工作流执行**深度安全扫描**,触发方式:
- **定时**:每周一凌晨 3 点(`cron: "0 3 * * 1"`)
- **手动**:`workflow_dispatch`,可指定 `target_url`(DAST 目标)与 `skip_dast` 选项
执行内容:
| 步骤 | 工具 | 类型 | 输出 |
|------|------|------|------|
| 依赖扫描 | npm audit | 依赖 | `audit-report.json` |
| 深度依赖 + 静态分析 | Snyk(`--severity-threshold=medium`) | 依赖 + 代码 | `snyk.sarif` |
| 文件系统扫描 | Trivy fs | 代码 + 依赖 | `trivy-fs-report.json` |
| 容器镜像扫描 | Trivy image | 容器 | `trivy-image-report.json` |
| DAST | OWASP ZAP baseline | 动态 | 控制台报告 |
| 汇总报告 | shell + jq | 汇总 | `security-summary.md` |
所有报告上传为 artifact `security-reports-full`
## 二、各扫描工具的作用
| 工具 | 作用 | 覆盖范围 |
|------|------|---------|
| **npm audit** | Node.js 依赖漏洞审计,基于 npm advisory 数据库 | 直接与间接 npm 依赖 |
| **Snyk** | 深度依赖分析 + 代码静态分析,漏洞库更广,含许可证检查 | npm 依赖 + 源码 |
| **Trivy(fs)** | 文件系统扫描,检测依赖锁文件、IaC 配置、密钥泄露 | 项目代码、配置、密钥 |
| **Trivy(image)** | 容器镜像扫描,检测镜像层漏洞与配置问题 | 构建出的 Docker 镜像 |
| **OWASP ZAP** | 动态应用安全测试(DAST),模拟攻击发现运行时漏洞 | 运行中的 Web 应用 |
## 三、如何处理扫描发现的漏洞
### 3.1 处理流程
1. **查看报告**:从 CI artifact 下载 `security-reports` / `security-reports-full`
2. **分级评估**:按漏洞等级确定处理优先级(见分级标准)
3. **修复或缓解**:
- 升级受影响依赖到修复版本
- 若无法立即升级,评估是否可接受并记录抑制项
- 对运行时漏洞,通过 WAF/配置/代码修复
4. **验证**:本地运行 `npm run security:scan` 验证修复效果
5. **记录**:更新抑制配置文件,记录处理决策
### 3.2 抑制配置文件
对于经评估确认可接受的漏洞,通过以下文件抑制:
- **`.gitea/suppressions.json`** — Snyk 漏洞抑制,每条需填写 `id``package``severity``reason``expires`(到期时间)、`owner`
- **`.trivyignore`** — Trivy 忽略的 CVE 列表,每行一个 CVE ID,带注释说明原因
> 抑制项到期后必须重新评估。`suppressions.json` 中 `policy.reviewCadenceDays: 30` 要求每 30 天复审一次。
### 3.3 必需的 Secrets
| Secret | 用途 | 必需性 |
|--------|------|--------|
| `SNYK_TOKEN` | Snyk API 令牌 | 推荐(无则 Snyk 步骤跳过) |
| `NEXTAUTH_URL` | ZAP DAST 扫描目标 URL | 可选(默认 localhost:8015) |
## 四、本地扫描方法
### 4.1 npm 脚本
```bash
# 仅依赖审计
npm run security:audit
# 完整本地扫描(npm audit + Trivy fs)
npm run security:scan
```
### 4.2 直接运行脚本
**Linux/macOS:**
```bash
chmod +x scripts/security-scan.sh
./scripts/security-scan.sh
```
**Windows PowerShell:**
```powershell
.\scripts\security-scan.ps1
```
### 4.3 退出码
| 退出码 | 含义 |
|--------|------|
| `0` | 无高危(critical/high)漏洞 |
| `1` | 存在高危漏洞,需尽快处理 |
### 4.4 前置依赖
- **Node.js + npm** — 必需
- **Trivy** — 可选(未安装则跳过文件系统扫描),[安装指南](https://aquasecurity.github.io/trivy/latest/getting-started/installation/)
- **jq**(仅 bash 脚本)— 可选(未安装则显示原始报告)
## 五、漏洞分级标准
| 等级 | 说明 | 示例 |
|------|------|------|
| **Critical** | 可被远程利用,导致 RCE、认证绕过、数据完全泄露 | 远程代码执行、SQL 注入 |
| **High** | 可导致权限提升、敏感数据泄露、服务中断 | XSS、认证缺陷、SSRF |
| **Medium** | 需特定条件触发,影响有限 | 信息泄露、CSRF |
| **Low** | 影响极小,通常为信息收集类 | 版本号泄露、低危 ReDoS |
## 六、修复 SLA(服务等级协议)
| 漏洞等级 | 修复时限 | 处理要求 |
|---------|---------|---------|
| Critical | 24 小时 | 立即修复或下线受影响服务,发布紧急补丁 |
| High | 7 天 | 优先排期修复,升级依赖或应用补丁 |
| Medium | 30 天 | 纳入迭代计划修复 |
| Low | 90 天 | 评估后决定修复或抑制 |
> 超过 SLA 未处理的漏洞需升级至安全负责人,并在 `suppressions.json` 中记录延期原因。
## 七、相关文件清单
| 文件 | 用途 |
|------|------|
| `.gitea/workflows/ci.yml` | 主 CI 流水线(含 security-scan job) |
| `.gitea/workflows/security.yml` | 独立深度安全扫描工作流 |
| `.gitea/suppressions.json` | Snyk 漏洞抑制配置 |
| `.trivyignore` | Trivy CVE 忽略列表 |
| `scripts/security-scan.sh` | 本地扫描脚本(Linux/macOS) |
| `scripts/security-scan.ps1` | 本地扫描脚本(Windows) |
| `scripts/audit.sh` | 依赖审计脚本(Linux/macOS) |
| `scripts/audit.ps1` | 依赖审计脚本(Windows) |

View File

@@ -0,0 +1,185 @@
# 视觉回归测试 (Visual Regression Testing)
本项目使用 [Playwright](https://playwright.dev/) 的 `toHaveScreenshot()` API 实现视觉回归测试,对关键页面在多种视口与主题下进行像素级快照对比,以捕获 UI 的意外变化。
## 目录结构
```
tests/visual/
├── visual.config.ts # 视觉测试配置(页面、视口、主题、快照路径)
├── homepage.spec.ts # 登录页视觉测试
├── admin-dashboard.spec.ts # 管理员仪表盘视觉测试
├── teacher-dashboard.spec.ts # 教师仪表盘视觉测试
├── student-dashboard.spec.ts # 学生仪表盘视觉测试
├── helpers/
│ ├── auth.ts # 认证辅助(登录、setupAuthState)
│ └── visual-helpers.ts # 视觉通用辅助(视口、主题、遮罩)
└── __screenshots__/ # 快照基线存储目录(自动生成)
```
## 覆盖范围
| 页面 | 路径 | 视口 | 主题 | 是否需要登录 |
|------|------|------|------|--------------|
| 登录页 | `/login` | desktop / tablet / mobile | light / dark | 否 |
| 管理员仪表盘 | `/admin/dashboard` | desktop / tablet / mobile | light / dark | 是 (admin) |
| 教师仪表盘 | `/teacher/dashboard` | desktop / tablet / mobile | light / dark | 是 (teacher) |
| 学生仪表盘 | `/student/dashboard` | desktop / tablet / mobile | light / dark | 是 (student) |
视口尺寸:
- desktop: 1920 × 1080
- tablet: 768 × 1024
- mobile: 375 × 812
## 运行测试
### 前置条件
- 需要启动开发服务器(Playwright 会通过 `webServer` 配置自动启动)
- 需要登录的视觉测试需要 `DATABASE_URL` 环境变量,否则会自动跳过
- 测试账号默认为 `admin@xiaoxue.edu.cn / 123456`,可通过环境变量覆盖
### 运行命令
```bash
# 运行所有视觉回归测试
npm run test:visual
# 运行单个测试文件
npx playwright test --project=visual-chromium tests/visual/homepage.spec.ts
# 以 UI 模式运行(便于调试)
npx playwright test --project=visual-chromium --ui
```
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DATABASE_URL` | - | 数据库连接串,未设置时需要登录的测试会跳过 |
| `VISUAL_ADMIN_EMAIL` | `admin@xiaoxue.edu.cn` | 管理员测试账号 |
| `VISUAL_ADMIN_PASSWORD` | `123456` | 管理员测试密码 |
| `VISUAL_TEACHER_EMAIL` | `admin@xiaoxue.edu.cn` | 教师测试账号 |
| `VISUAL_TEACHER_PASSWORD` | `123456` | 教师测试密码 |
| `VISUAL_STUDENT_EMAIL` | `admin@xiaoxue.edu.cn` | 学生测试账号 |
| `VISUAL_STUDENT_PASSWORD` | `123456` | 学生测试密码 |
## 更新基线
当 UI 发生**预期内**的变化时,需要更新快照基线:
```bash
# 更新所有视觉快照基线
npm run test:visual:update
# 更新单个测试文件的基线
npx playwright test --project=visual-chromium tests/visual/homepage.spec.ts --update-snapshots
```
更新后的快照应作为 PR 的一部分提交到版本库,以便团队评审 UI 变更。
## 处理误报
视觉测试可能因为动态内容(时间戳、用户名、实时数据等)产生误报。本项目通过以下方式消除误报:
### 1. 动态元素遮罩
`maskDynamicElements()` 辅助函数会自动遮罩以下选择器:
- `[data-testid='timestamp']`
- `[data-testid='current-time']`
- `[data-testid='user-avatar']`
- `[data-testid='user-name']`
- `time`
- `[data-visual-dynamic]`
可在测试中追加额外需要遮罩的选择器:
```ts
const masks = await maskDynamicElements(page, ["[data-testid='stat-card-value']"])
```
### 2. 标记动态元素
在组件代码中为动态元素添加 `data-visual-dynamic` 属性,即可自动被遮罩:
```tsx
<div data-visual-dynamic>{new Date().toLocaleString()}</div>
```
### 3. 调整容差
`playwright.config.ts` 中配置了默认容差 `maxDiffPixelRatio: 0.01`(允许 1% 像素差异)。若特定页面需要更宽松的容差,可在断言时覆盖:
```ts
await expect(page).toHaveScreenshot("name.png", {
maxDiffPixelRatio: 0.05,
})
```
### 4. 禁用动画
默认配置 `animations: "disabled"`,避免动画过渡态导致快照不稳定。
## CI 集成
### GitHub Actions 示例
```yaml
name: Visual Regression
on:
pull_request:
paths:
- "src/**"
- "tests/visual/**"
- "playwright.config.ts"
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# 启动数据库(按需)
- run: npm run db:setup
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Run visual tests
run: npm run test:visual
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
CI: "true"
- name: Upload snapshot diff
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshot-diff
path: test-results/
retention-days: 7
```
### CI 注意事项
1. **快照基线需提交到版本库**: `tests/visual/__screenshots__/` 目录应纳入 Git 跟踪
2. **跨平台一致性**: 不同操作系统的字体渲染存在差异,建议 CI 与本地使用相同的 Linux 容器环境。若本地为 Windows/macOS,可能出现少量误报,以 CI 结果为准
3. **storageState 缓存**: `tests/visual/.auth/` 目录应加入 `.gitignore`,不要提交登录态文件
## 与 E2E 测试的关系
| 维度 | E2E 测试 | 视觉测试 |
|------|----------|----------|
| 目录 | `tests/e2e/` | `tests/visual/` |
| Playwright 项目 | `chromium` | `visual-chromium` |
| 运行命令 | `npm run test:e2e` | `npm run test:visual` |
| 关注点 | 功能正确性 | UI 视觉一致性 |
| 断言方式 | DOM/行为断言 | 像素快照对比 |
两个测试套件相互独立,可分别运行,互不影响。

View File

@@ -2,6 +2,50 @@
## 2026-06-17
### P2 质量保障类实现5 项全部完成)
#### 1. 屏幕阅读器兼容性增强a11y
- 新增无障碍工具库:`src/shared/lib/a11y.ts`useA11yId/mergeA11yProps/describeInput/loadingAria
- 新增 Hook`src/shared/hooks/use-aria-live.ts`aria-live 区域管理)
- 新增组件:`src/shared/components/a11y/`skip-link/visually-hidden/focus-trap/aria-status
- 增强 UI 组件table.tsx 添加系统性 ARIA roledialog.tsx 添加 aria-modal
- 审计文档:`docs/accessibility/a11y-audit.md`(含 WCAG 2.1 AA 合规清单)
#### 2. 视觉回归测试Visual Regression
- 配置:`tests/visual/visual.config.ts`3 视口 × 2 主题)
- 测试套件homepage/admin-dashboard/teacher-dashboard/student-dashboard
- 辅助函数auth.ts登录态管理、visual-helpers.ts视口/主题/遮罩)
- 更新 playwright.config.ts新增 visual-chromium 项目maxDiffPixelRatio 0.01
- 文档:`docs/testing/visual-regression.md`
#### 3. 短信/微信推送渠道集成notifications
- 新增模块:`src/modules/notifications/`
- 渠道实现SMS阿里云/腾讯云/Mock、WeChat公众号模板消息、EmailNodemailer SMTP、In-App
- 分发器:按用户通知偏好并行多渠道发送
- Server ActionssendNotificationAction、sendClassNotificationAction
- 外部 SDK 动态 importMock 模式开发环境可用
- 配置:`.env.example`,文档:`docs/notifications/channels.md`
#### 4. 漏洞扫描 CI 集成security
- 增强 CIsecurity-scan jobnpm audit + Snyk + Trivy FS + OWASP ZAP
- 独立工作流:`.gitea/workflows/security.yml`(每周一深度扫描,含容器镜像扫描)
- 配置:`.gitea/suppressions.json`Snyk 抑制)、`.trivyignore`Trivy CVE 忽略)
- 本地脚本:`scripts/security-scan.sh` + `scripts/security-scan.ps1`
- 文档:`docs/security/scanning.md`(含 SLAcritical 24h/high 7d/medium 30d/low 90d
#### 5. 灾备方案DR
- 脚本backup-verify.sh完整性校验、backup-offsite-sync.shS3/OSS/NFS 异地同步、dr-drill.sh/ps1灾备演练、failover.sh故障切换、health-check.sh健康检查
- CI 增强scheduled-backup 添加校验+异地同步,新增 weekly-dr-drill job
- 独立工作流:`.gitea/workflows/dr-drill.yml`(每周一凌晨 4 点自动演练)
- 文档:`docs/dr/dr-plan.md`RTO 4h/RPO 24h`docs/dr/dr-runbook.md`6 大故障场景操作手册)
- 配置:`.env.example` 灾备环境变量
#### 验证
- `npx tsc --noEmit`0 错误
- `npm run lint`0 错误 0 警告
---
### P2 功能扩展类实现(功能扩展 + 质量保障首批)
#### 1. 选课管理模块elective